From 51df078b47c348387ae4ed0923b653093f3455cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 17 Jul 2026 16:05:15 +0200 Subject: [PATCH 1/5] feat: let a `[Module]` self-compile its own `[Scan]` (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(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. --- Docs/pages/09-diagnostics.md | 77 ++++- Docs/pages/registration/07-scanning.md | 4 + Docs/pages/registration/08-modules.md | 20 +- .../AnalyzerReleases.Unshipped.md | 5 +- .../AwaitenGenerator.Collection.cs | 23 +- .../AwaitenGenerator.ModuleScan.cs | 274 ++++++++++++++++++ .../AwaitenGenerator.cs | 96 +++++- .../Awaiten.SourceGenerators/Diagnostics.cs | 75 ++++- .../Entities/ModuleScanModel.cs | 36 +++ .../Sources/Sources.Module.cs | 108 +++++++ .../DiagnosticTests.Awt154ScanOnModule.cs | 127 -------- ...gnosticTests.Awt194ModuleScanNotPartial.cs | 70 +++++ ...ts.Awt195Awt196Awt197ModuleScanExposure.cs | 98 +++++++ .../SelfCompiledModuleScanTests.cs | 151 ++++++++++ .../TestHelpers/Generator.cs | 14 + 15 files changed, 1008 insertions(+), 170 deletions(-) create mode 100644 Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs create mode 100644 Source/Awaiten.SourceGenerators/Entities/ModuleScanModel.cs create mode 100644 Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs delete mode 100644 Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt154ScanOnModule.cs create mode 100644 Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt194ModuleScanNotPartial.cs create mode 100644 Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt195Awt196Awt197ModuleScanExposure.cs create mode 100644 Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index 14ac436..75f8ef4 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -1332,19 +1332,7 @@ public static class EquipmentModule ### AWT154 -:::danger[Error] -An imported module declares a `[Scan]`, which is not collected from modules. -::: - -```csharp -[Module] -[Scan] // scans are not gathered from modules -public static class MenuModule; - -[Container] -[Import(typeof(MenuModule))] -public static partial class CoffeeShop; -``` +*Retired.* A `[Scan]` on a `[Module]` is now supported: the module compiles its own scan in its own build (see [self-compiled module scans](./registration/modules#self-compiled-scans)), so it is no longer rejected on import. The self-compilation constraints are reported as [AWT194](#awt194)–[AWT197](#awt197). ### AWT155 @@ -1367,6 +1355,69 @@ public static class ModuleB; public static partial class CoffeeShop; ``` +### AWT194 + +:::danger[Error] +A `[Module]` that declares a `[Scan]` is not `partial`, so its scan cannot be self-compiled. +::: + +```csharp +[Module] +[Scan(As = ScanAs.MatchingInterface)] +public static class PluginModule; // must be partial to receive the generated factories +``` + +A [self-compiled module scan](./registration/modules#self-compiled-scans) emits a factory method and a registration attribute into the module's partial. Add the `partial` modifier. Reported in the module's own build. + +### AWT195 + +:::danger[Error] +A self-compiled module `[Scan]` match has a constructor parameter of a type inaccessible outside the module's assembly. +::: + +```csharp +internal sealed class Secret; +internal sealed class Roaster(Secret secret) : IRoaster; // Secret is internal + +[Module] +[Scan(As = ScanAs.MatchingInterface)] +public static partial class PluginModule; +``` + +The generated factory is a `public` method whose parameters are resolved from the *consuming* container's graph, so each parameter type has to be nameable by the consumer. Widen the parameter type's accessibility, or exclude the match. (A v1 limitation: a self-compiled scan cannot construct a match through an inaccessible parameter.) + +### AWT196 + +:::danger[Error] +A self-compiled module `[Scan]` match has no exposure interface accessible outside the module's assembly. +::: + +```csharp +internal sealed class Roaster : IPlugin; + +[Module] +[Scan(As = ScanAs.Self)] // Self exposes the internal type, which a consumer cannot name +public static partial class PluginModule; +``` + +A consumer resolves a self-compiled match only through an accessible interface. `ScanAs.Self` over an `internal` implementation exposes nothing nameable; expose it through a public interface (`ScanAs.MatchingInterface` or `ScanAs.Marker`), or exclude the match. + +### AWT197 + +:::danger[Error] +A self-compiled module `[Scan]` match would be exposed under more than one interface. +::: + +```csharp +internal sealed class Roaster : IPlugin, IRoaster; + +[Module] +[Scan(As = ScanAs.Marker | ScanAs.MatchingInterface)] // IPlugin and IRoaster both apply +public static partial class PluginModule; +``` + +A self-compiled match is reached through a generated factory that returns a single accessible interface, so a shared instance across several interfaces cannot be expressed (unlike a container `[Scan]`, whose matches coalesce on the concrete type). Narrow the exposure to a single interface (typically `ScanAs.MatchingInterface`), or exclude the match. This is a v1 limitation. + ## Keyed collections ### AWT159 diff --git a/Docs/pages/registration/07-scanning.md b/Docs/pages/registration/07-scanning.md index 4f1afce..b0595d2 100644 --- a/Docs/pages/registration/07-scanning.md +++ b/Docs/pages/registration/07-scanning.md @@ -110,6 +110,10 @@ An explicit registration of a scanned type wins over the scan, so you can specia Prefer explicit registrations. A scan trades away the property that makes the composition root useful: the whole graph visible in one place. Reach for a scan only for a large, uniform family that grows on its own, like message handlers, validators, or plug-ins, where listing each one adds churn without adding clarity. For a handful of services, spell them out, see [Design principles](../design-principles#when-power-becomes-a-smell). ::: +## Scan from a module + +A `[Scan]` may also sit on a `[Module]`, where it is compiled in the module's own build so a library can scan its `internal` implementations and expose them to consumers through their interfaces. See [self-compiled scans](./modules#self-compiled-scans). + ## Where to go next - [Modules](./modules) to group registrations for reuse. diff --git a/Docs/pages/registration/08-modules.md b/Docs/pages/registration/08-modules.md index d2630f3..49c855c 100644 --- a/Docs/pages/registration/08-modules.md +++ b/Docs/pages/registration/08-modules.md @@ -54,9 +54,27 @@ public static class ProductionModule } ``` +## Self-compiled scans + +A library often keeps its implementations `internal` and exposes only interfaces. A consuming container cannot construct an inaccessible type, so it could never register one — unless the library hand-wrote a factory per type. A `[Scan]` on a `[Module]` closes that gap: the module compiles its own scan **in its own build**, emitting a factory per match that constructs the implementation (which its own assembly can see) and returns the accessible interface. To the consumer this is an ordinary module factory registration, so nothing new crosses the assembly boundary. + +```csharp +public interface IClock; +public interface IRoaster; +internal sealed class Roaster(IClock clock) : IPlugin, IRoaster; // stays internal + +[Module] +[Scan(As = ScanAs.MatchingInterface, Lifetime = AwaitenLifetime.Singleton)] +public static partial class PluginModule; // partial, so the generator can add the factory +``` + +A consuming container `[Import]`s the module and resolves `IRoaster` without ever naming `Roaster`. The generated registration is an overridable default (`Fallback.Silent`), so the container can replace it with its own registration. Because diagnostics are reported in the *library's* build, the library author — not the consumer — sees any problem. + +The module must be `partial` ([AWT194](../diagnostics#awt194)). A few v1 limitations apply, each reported at the library's source: a match exposes through exactly one accessible interface ([AWT196](../diagnostics#awt196)/[AWT197](../diagnostics#awt197)), and its constructor parameters must be types a consumer can name ([AWT195](../diagnostics#awt195)). Self-compilation is a cross-assembly feature: within a single assembly the container can already `[Scan]` its own `internal` types directly. + ## One level deep -Imports are not transitive. A module's own `[Import]` is not followed, and a `[Scan]` inside a module is not collected. Keep the container as the single place that composes modules. +Imports are not transitive. A module's own `[Import]` is not followed. Keep the container as the single place that composes modules. (A module's own `[Scan]`, by contrast, is self-compiled in the module's build — see above — not collected by the importing container.) *Note: importing a type that is not a `[Module]` is an error ([AWT149](../diagnostics#awt149)), and a module must be static ([AWT152](../diagnostics#awt152)).* diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index fd0f4fc..689d48f 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -55,7 +55,6 @@ AWT151 | Awaiten | Warning | An imported module declares no registrations AWT152 | Awaiten | Error | An imported [Module] class is not declared static AWT153 | Awaiten | Error | A module Factory/Instance member is not accessible from the generated container - AWT154 | Awaiten | Error | An imported module declares a [Scan], which is not collected from modules AWT155 | Awaiten | Warning | Two imported modules strongly register the same service with different implementations AWT156 | Awaiten | Warning | A generated Root/Scope is disposed synchronously although its container owns a service that implements IAsyncDisposable but not IDisposable AWT157 | Awaiten | Error | An [Inject(Optional = true)] property is required and cannot be omitted from the object initializer @@ -95,3 +94,7 @@ AWT191 | Awaiten | Error | An OnRelease hook parameter is a Func/Lazy relationship, which would defer resolution past the owner's teardown AWT192 | Awaiten | Error | SuppressDisposal is set on a pre-built Instance, which the container does not own or dispose, so it has no effect AWT193 | Awaiten | Warning | A [Scan] matched a type that is inaccessible to the generated container, so it is not registered + AWT194 | Awaiten | Error | A [Module] that declares a [Scan] is not partial, so its scan cannot be self-compiled + AWT195 | Awaiten | Error | A self-compiled module [Scan] match has a constructor parameter type inaccessible outside the module's assembly + AWT196 | Awaiten | Error | A self-compiled module [Scan] match has no exposure interface accessible outside the module's assembly + AWT197 | Awaiten | Error | A self-compiled module [Scan] match would be exposed under multiple interfaces, which a single-exposure factory cannot express diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs index 20ac8fe..f39dee2 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs @@ -233,10 +233,11 @@ private static List CollectImportedModules(INamedTypeSymbol cont } /// - /// Validates one [Import(typeof(Module))] target, reporting AWT149-154, and returns whether it is + /// Validates one [Import(typeof(Module))] target, reporting AWT149-152, and returns whether it is /// importable. A non-[Module] target (AWT149) is skipped (); the other faults /// are reported but still imported (): non-static (AWT152), a nested [Import] - /// (AWT150), a module-declared [Scan] (AWT154), or no registrations (AWT151). + /// (AWT150), or no registrations (AWT151). A module-declared [Scan] is accepted (self-compiled in the + /// module's own build, AWT154 retired) rather than rejected. /// private static bool ValidateImportedModule( INamedTypeSymbol module, @@ -273,16 +274,9 @@ private static bool ValidateImportedModule( Diagnostics.NestedModuleImport, location, new EquatableArray([moduleName,]))); } - // AWT154: [Scan] sweeps an assembly relative to the container and is not collected from modules, so a - // module-declared scan would be silently ignored; reject it instead. Reported at the module's own [Scan] - // when in source, else at the container's [Import]. - if (TryGetAwaitenAttribute(moduleAttributes, "ScanAttribute", out AttributeData? scan)) - { - diagnostics.Add(new DiagnosticInfo( - Diagnostics.ScanOnModule, - LocationInfo.From(scan?.ApplicationSyntaxReference?.GetSyntax().GetLocation()) ?? location, - new EquatableArray([moduleName,]))); - } + // A [Scan] on a module is self-compiled in the module's own build (the module emits a generated factory + // and lifetime registration per match, which this collection reads like any other module registration), + // so the consumer neither re-runs it nor rejects it. See ExpandModuleScan; AWT154 was retired. // AWT151: a module that declares no lifetime registrations imports nothing useful. if (!DeclaresAnyRegistration(moduleAttributes)) @@ -298,8 +292,9 @@ private static bool ValidateImportedModule( /// Whether an attribute list carries anything a module contributes to an importing container: a /// [Singleton]/[Transient]/[Scoped] lifetime registration (generic or open /// typeof form), a [Decorate], a [Composite], or [ImportServices]. Used to - /// detect a module that declares nothing to import (AWT151); a module-declared [Scan] does not - /// count, being uncollected and its own error (AWT154). + /// detect a module that declares nothing to import (AWT151). A module-declared [Scan] does not + /// count here on its own: its matches are self-compiled into generated lifetime registration attributes + /// (which do count), so a scan-only module whose scan matched something carries those generated attributes. /// private static bool DeclaresAnyRegistration(ImmutableArray attributes) { diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs new file mode 100644 index 0000000..c94c68c --- /dev/null +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs @@ -0,0 +1,274 @@ +using Awaiten.SourceGenerators.Entities; +using Awaiten.SourceGenerators.Internals; +using Microsoft.CodeAnalysis; + +namespace Awaiten.SourceGenerators; + +partial class AwaitenGenerator +{ + /// + /// Expands every [Scan] declared on a [Module] into the factories the module self-compiles: + /// one public static method per match that constructs the (possibly internal) implementation + /// and returns its accessible exposure interface. The emitter pairs each with a single-argument lifetime + /// registration attribute ([Singleton<IExposure>(Factory = …)]), so to a consuming container + /// the scan is indistinguishable from a hand-written module factory - no new cross-assembly ABI. Runs in the + /// module's own build, so diagnostics land at the library's source locations, and the scan sees the + /// library's internal types (the whole point: keep implementations internal, expose interfaces). + /// Reuses the container scan's candidate discovery, filters and their diagnostics + /// (AWT138/AWT140/AWT143/AWT172/AWT173/AWT174/AWT183/AWT184/AWT185), and adds AWT195/AWT196/AWT197 for the + /// self-compilation constraints (accessible parameters, a single accessible exposure per match). + /// + private static List CollectModuleScanFactories( + INamedTypeSymbol module, + Compilation compilation, + List diagnostics) + { + List factories = new(); + + foreach (AttributeData attribute in module.GetAttributes()) + { + if (attribute.AttributeClass is not { Name: "ScanAttribute", } attributeClass + || attributeClass.ContainingNamespace?.ToDisplayString() != AttributeNamespace) + { + continue; + } + + if (ScanMarker(attribute, attributeClass) is { } marker) + { + ExpandModuleScan(attribute, marker, module, compilation, factories, diagnostics); + } + else if (IsMarkerlessScan(attribute, attributeClass)) + { + ExpandModuleScan(attribute, null, module, compilation, factories, diagnostics); + } + } + + return factories; + } + + /// + /// Expands one module [Scan] over its candidates, mirroring but emitting a + /// generated factory per match instead of a . A module scans its own assembly + /// (or the assemblies named by InAssembliesOf), so an internal match is legitimate. + /// + private static void ExpandModuleScan( + AttributeData attribute, + INamedTypeSymbol? marker, + INamedTypeSymbol module, + Compilation compilation, + List factories, + List diagnostics) + { + Location? location = attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation(); + + List? assemblies = ScanAssemblies(attribute); + if (assemblies is { Count: 0, }) + { + diagnostics.Add(new DiagnosticInfo(Diagnostics.ScanAssembliesEmpty, LocationInfo.From(location), new EquatableArray([]))); + return; + } + + ScanMatch match = new(ScanExposureOf(attribute), ScanLifetime(attribute), location, ScanSkipsUnconstructable(attribute)); + ScanFilters filters = ScanFiltersOf(attribute); + + if ((match.Exposure & ScanExposures.All) == 0) + { + diagnostics.Add(new DiagnosticInfo(Diagnostics.ScanExposesNothing, LocationInfo.From(location), new EquatableArray([]))); + return; + } + + if (marker is null && MarkerlessScanError(match.Exposure, filters, assemblies) is { } reason) + { + diagnostics.Add(new DiagnosticInfo(Diagnostics.MarkerlessScanInvalid, LocationInfo.From(location), new EquatableArray([reason,]))); + return; + } + + bool openMarker = marker is not null && IsOpenGenericMarker(marker); + INamedTypeSymbol? markerDefinition = marker?.OriginalDefinition; + + string? markerDisplay = null; + if (marker is not null) + { + markerDisplay = (openMarker ? markerDefinition! : marker).ToDisplayString(FullyQualified); + } + + ScanFilterHits hits = new(); + int assignable = 0; + int registered = 0; + int produced = 0; + foreach (ScanCandidate candidate in ScanCandidates(assemblies, compilation, marker, location, diagnostics)) + { + assignable++; + if (!PassesScanFilters(candidate.Type, filters, hits)) + { + continue; + } + + registered++; + + // A private/protected nested type the module cannot name is skipped silently: unlike a container scan + // (AWT193), a module scans its own assembly, so an internal implementation is not inaccessible here and + // is the expected match. Only a truly unnameable candidate reaches this, which no author asked for. + if (candidate.Inaccessible) + { + continue; + } + + produced += RegisterModuleScanMatch(candidate.Type, match, marker, openMarker, markerDefinition, module, compilation, factories, diagnostics); + } + + ReportScanFilterDiagnostics(filters, hits, new ScanFilterCounts(assignable, registered, produced), assemblies, markerDisplay, location, diagnostics); + } + + /// + /// Emits the factory for one module-scan match, or reports why it cannot: the exposure must resolve to a + /// single interface a consumer in another assembly can name (AWT196 when none, AWT197 when several, since a + /// factory returns one accessible type and a shared instance across interfaces cannot be expressed), and the + /// constructor's parameters must all be accessible outside the assembly (AWT195), because they appear on the + /// generated public factory and are resolved from the consumer's graph. Returns the number of + /// factories contributed (0 or 1). + /// + private static int RegisterModuleScanMatch( + INamedTypeSymbol type, + ScanMatch match, + INamedTypeSymbol? marker, + bool openMarker, + INamedTypeSymbol? markerDefinition, + INamedTypeSymbol module, + Compilation compilation, + List factories, + List diagnostics) + { + string typeName = type.ToDisplayString(FullyQualified); + + // The interfaces this match would expose, restricted to those a consumer in any assembly can name. Self + // exposes the concrete type, externally usable only when the type itself is public. + List exposures = new(); + if (match.RegisterSelf && IsExternallyAccessible(type)) + { + exposures.Add(type); + } + + if (match.RegisterMarker && marker is not null) + { + exposures.AddRange( + (openMarker ? ClosedMarkerInterfaces(type, markerDefinition!) : MarkerInterfaces(type, marker, compilation)) + .Where(IsExternallyAccessible)); + } + + if (match.RegisterMatchingInterface) + { + exposures.AddRange(MatchingInterfaces(type).Where(IsExternallyAccessible)); + } + + // A combined Marker | MatchingInterface can select the same interface twice; dedup by name. + List distinct = new(); + HashSet seen = new(StringComparer.Ordinal); + foreach (INamedTypeSymbol exposure in exposures) + { + if (seen.Add(exposure.ToDisplayString(FullyQualified))) + { + distinct.Add(exposure); + } + } + + if (distinct.Count == 0) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ModuleScanNoAccessibleExposure, + LocationInfo.From(match.Location), + new EquatableArray([Display(typeName),]))); + return 0; + } + + if (distinct.Count > 1) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ModuleScanMultipleExposures, + LocationInfo.From(match.Location), + new EquatableArray([ + Display(typeName), + string.Join(", ", distinct.Select(exposure => Display(exposure.ToDisplayString(FullyQualified)))), + ]))); + return 0; + } + + INamedTypeSymbol service = distinct[0]; + + // The constructor the generated factory calls. Accessibility is asked against the module (it emits the + // 'new'), so an internal constructor in the module's own assembly qualifies. A greediest-fallback keeps a + // type with no satisfiable-here constructor building (its parameters are resolved by the consumer, not us). + IMethodSymbol? constructor = SelectConstructor( + type, module, compilation, Array.Empty(), new ExternalSurface(false, new HashSet(StringComparer.Ordinal)), _ => true); + if (constructor is null) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.NoAccessibleConstructor, + LocationInfo.From(match.Location), + new EquatableArray([Display(typeName),]))); + return 0; + } + + List parameters = new(); + foreach (IParameterSymbol parameter in constructor.Parameters) + { + // The parameter type appears on the public factory signature and is resolved from the consumer's graph, + // so it must be nameable outside the assembly (AWT195). This is the v1 limitation on self-compiled scans. + if (!IsExternallyAccessible(parameter.Type)) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ModuleScanParameterInaccessible, + LocationInfo.From(match.Location), + new EquatableArray([Display(typeName), Display(parameter.Type.ToDisplayString(FullyQualified)),]))); + return 0; + } + + parameters.Add(new FactoryParameter(parameter.Type.ToDisplayString(FullyQualified), parameter.Name)); + } + + factories.Add(new ModuleFactory( + ModuleFactoryName(type, factories.Count), + service.ToDisplayString(FullyQualified), + typeName, + match.Lifetime, + new EquatableArray(parameters.ToArray()))); + return 1; + } + + /// + /// A deterministic, unique factory method name for one match: prefixed to avoid clashing with the module's + /// own members, suffixed with the running index so two matches of the same simple name never collide (which + /// would surface as an ambiguous factory on the consumer). + /// + private static string ModuleFactoryName(INamedTypeSymbol type, int index) + => $"Awaiten__Scan_{index}_{type.Name}"; + + /// + /// Whether a type can be named from an unrelated assembly: it (and every enclosing type) is public, + /// and any generic type arguments are themselves externally accessible. An array is accessible when its + /// element type is. Used to gate a self-compiled scan's exposure interface and its factory's parameter types, + /// which appear on a public generated signature. + /// + private static bool IsExternallyAccessible(ITypeSymbol type) + { + switch (type) + { + case IArrayTypeSymbol array: + return IsExternallyAccessible(array.ElementType); + case ITypeParameterSymbol: + return true; + case INamedTypeSymbol named: + for (INamedTypeSymbol? current = named; current is not null; current = current.ContainingType) + { + if (current.DeclaredAccessibility != Accessibility.Public) + { + return false; + } + } + + return named.TypeArguments.All(IsExternallyAccessible); + default: + return false; + } + } +} diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs index 744e580..c7e3c8c 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs @@ -2,6 +2,7 @@ using Awaiten.SourceGenerators.Entities; using Awaiten.SourceGenerators.Internals; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; @@ -23,6 +24,7 @@ namespace Awaiten.SourceGenerators; public sealed partial class AwaitenGenerator : IIncrementalGenerator { private const string ContainerAttributeName = "Awaiten.ContainerAttribute"; + private const string ModuleAttributeName = "Awaiten.ModuleAttribute"; private const string AttributeNamespace = "Awaiten"; /// @@ -45,6 +47,98 @@ public void Initialize(IncrementalGeneratorInitializationContext context) spc.AddSource(model.HintName, SourceText.From(Sources.Emit(model), Encoding.UTF8)); }); + + // A second root: a [Module] that declares a [Scan] self-compiles it, emitting a factory + registration per + // match into its own partial (BuildModuleModel returns null for a plain module, which emits nothing). + IncrementalValuesProvider moduleModels = context.SyntaxProvider + .ForAttributeWithMetadataName( + ModuleAttributeName, + static (node, _) => node is ClassDeclarationSyntax, + static (ctx, ct) => BuildModuleModel(ctx, ct)) + .Where(static model => model is not null) + .Select(static (model, _) => model!); + + context.RegisterSourceOutput(moduleModels, static (spc, model) => + { + foreach (DiagnosticInfo diagnostic in model.Diagnostics.AsArray()) + { + spc.ReportDiagnostic(diagnostic.ToDiagnostic()); + } + + // No factories means nothing to add (an errored or empty scan reports its diagnostics above); emitting + // an empty partial would be noise. + if (model.Factories.Count > 0) + { + spc.AddSource(model.HintName, SourceText.From(Sources.EmitModule(model), Encoding.UTF8)); + } + }); + } + + /// + /// Builds the for a [Module] that declares a [Scan] (returning + /// for a module without one, which self-compiles nothing). The module must be + /// partial to receive the generated factories and registration attributes (AWT194); when it is, its + /// scans are expanded into factories in its own build (see ). + /// + private static ModuleScanModel? BuildModuleModel(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) + { + if (context.TargetSymbol is not INamedTypeSymbol moduleSymbol) + { + return null; + } + + // Only a module that declares a [Scan] self-compiles; a plain [Module] carries no generated code (and pays + // no analysis cost beyond this check). + if (!HasAwaitenAttribute(moduleSymbol.GetAttributes(), "ScanAttribute")) + { + return null; + } + + Compilation compilation = context.SemanticModel.Compilation; + List diagnostics = new(); + + // The module must be partial for the generator to add the factories and registration attributes. Every part + // of a partial type carries the partial modifier, so the declaration bearing the [Module] attribute suffices. + bool isPartial = context.TargetNode is ClassDeclarationSyntax declaration + && declaration.Modifiers.Any(SyntaxKind.PartialKeyword); + + List factories = new(); + if (!isPartial) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.NonPartialModuleScan, + LocationInfo.From(moduleSymbol.Locations.FirstOrDefault()), + new EquatableArray([Display(moduleSymbol.ToDisplayString(FullyQualified)),]))); + } + else + { + factories = CollectModuleScanFactories(moduleSymbol, compilation, diagnostics); + } + + string? moduleNamespace = moduleSymbol.ContainingNamespace is { IsGlobalNamespace: false, } ns + ? ns.ToDisplayString() + : null; + + List containingTypes = new(); + for (INamedTypeSymbol? outer = moduleSymbol.ContainingType; outer is not null; outer = outer.ContainingType) + { + containingTypes.Insert(0, new TypeDeclaration(KeywordOf(outer), outer.Name)); + } + + string typePath = containingTypes.Count > 0 + ? $"{string.Join("+", containingTypes.Select(t => t.Name))}+{moduleSymbol.Name}" + : moduleSymbol.Name; + string hintName = moduleNamespace is null + ? $"Awaiten.ModuleScan.{typePath}.g.cs" + : $"Awaiten.ModuleScan.{moduleNamespace}.{typePath}.g.cs"; + + return new ModuleScanModel( + moduleNamespace, + new EquatableArray(containingTypes.ToArray()), + moduleSymbol.Name, + hintName, + new EquatableArray(factories.ToArray()), + new EquatableArray(diagnostics.ToArray())); } private static ContainerModel? BuildModel(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) @@ -208,7 +302,7 @@ internal static GraphModel BuildGraph( AsyncDisposableSupport(compilation), compilation.GetTypeByMetadataName("Awaiten.IAsyncInitializable")); - // The imported modules, resolved once. Import validation (AWT149-152, AWT154) is reported while collecting. + // The imported modules, resolved once. Import validation (AWT149-152) is reported while collecting. List modules = CollectImportedModules(containerSymbol, diagnostics); // [ImportServices]: any otherwise-unresolved direct dependency falls through to the external provider diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index 7855f6b..eddcf8a 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -787,19 +787,9 @@ internal static class Diagnostics DiagnosticSeverity.Error, isEnabledByDefault: true); - /// - /// An imported [Module] carries a [Scan]. Assembly scanning is a container concern (it - /// sweeps assemblies relative to the container) and is not collected from modules, so a module-declared - /// scan would contribute nothing. This is an error rather than a warning so the scan is not silently - /// dropped. Move it onto the container. - /// - public static readonly DiagnosticDescriptor ScanOnModule = new( - "AWT154", - "Scan on module not supported", - "The imported module '{0}' declares a [Scan], which is not collected from modules and contributes nothing; move the [Scan] onto the container", - "Awaiten", - DiagnosticSeverity.Error, - isEnabledByDefault: true); + // AWT154 (ScanOnModule) was retired: a [Scan] on a [Module] is now supported and self-compiled in the + // module's own build (the module emits a generated factory + registration per match), so a module-declared + // scan no longer errors on import. See ExpandModuleScan and the AWT194-AWT197 diagnostics. /// /// Two different imported modules register the same service key with different implementations at @@ -1394,4 +1384,63 @@ internal static class Diagnostics "Awaiten", DiagnosticSeverity.Warning, isEnabledByDefault: true); + + /// + /// A [Module] carries a [Scan] but is not declared partial, so the generator cannot + /// add the factory methods and registration attributes that self-compile the scan into it. Add the + /// partial modifier. Reported in the module's own build, at the module's declaration. + /// + public static readonly DiagnosticDescriptor NonPartialModuleScan = new( + "AWT194", + "Module with a scan is not partial", + "the module '{0}' declares a [Scan] but is not partial, so its scan cannot be self-compiled; add the partial modifier to the module class", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// A self-compiled module [Scan] matched a type whose constructor takes a parameter of a type that + /// is not accessible outside the module's assembly. The generated factory is a public method that + /// resolves that parameter from the consuming container's graph, so the parameter type has to be nameable + /// by the consumer. Widen the parameter type's accessibility, or exclude the match from the scan. In v1 a + /// self-compiled scan cannot construct a match through an inaccessible parameter. + /// + public static readonly DiagnosticDescriptor ModuleScanParameterInaccessible = new( + "AWT195", + "Module scan match has an inaccessible constructor parameter", + "'{0}' matched the module scan, but its constructor parameter of type '{1}' is not accessible outside the module's assembly, so the generated factory cannot expose it; widen the parameter type's accessibility or exclude the type from the scan", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// A self-compiled module [Scan] matched a type but no exposure selected an interface accessible + /// outside the module's assembly, so a consumer could never name what the match registers under. A + /// ScanAs.Self exposure of an internal implementation hits this (the implementation type is + /// itself inaccessible), as does a match whose only convention/marker interface is internal. Expose + /// an accessible interface (ScanAs.MatchingInterface or ScanAs.Marker over a public + /// interface), or exclude the type from the scan. + /// + public static readonly DiagnosticDescriptor ModuleScanNoAccessibleExposure = new( + "AWT196", + "Module scan match has no accessible exposure", + "'{0}' matched the module scan, but no exposure selected an interface accessible outside the module's assembly, so a consumer could not resolve it; expose it through a public interface (ScanAs.MatchingInterface or ScanAs.Marker) or exclude it from the scan", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// A self-compiled module [Scan] matched a type that would be exposed under more than one accessible + /// interface. A self-compiled match is reached only through a generated factory that returns a single + /// accessible interface, so a shared instance across several interfaces cannot be expressed (unlike a + /// container scan, whose matches coalesce on the concrete type). In v1 narrow the exposure to a single + /// interface (typically ScanAs.MatchingInterface), or exclude the type from the scan. + /// + public static readonly DiagnosticDescriptor ModuleScanMultipleExposures = new( + "AWT197", + "Module scan match has multiple exposures", + "'{0}' matched the module scan, but it would be exposed under multiple interfaces ({1}); a self-compiled scan supports a single exposure per match, so narrow the As to one interface or exclude the type from the scan", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } diff --git a/Source/Awaiten.SourceGenerators/Entities/ModuleScanModel.cs b/Source/Awaiten.SourceGenerators/Entities/ModuleScanModel.cs new file mode 100644 index 0000000..361e04c --- /dev/null +++ b/Source/Awaiten.SourceGenerators/Entities/ModuleScanModel.cs @@ -0,0 +1,36 @@ +using Awaiten.SourceGenerators.Internals; +using Microsoft.CodeAnalysis; + +namespace Awaiten.SourceGenerators.Entities; + +/// +/// The equatable model of a [Module] that compiles its own [Scan]: the partial class to +/// re-open and the factories to emit into it, one per scan match. The generator adds a partial declaration +/// carrying a generated public static factory per match (which news the — possibly +/// internal — implementation and returns its accessible exposure interface) plus a matching lifetime +/// registration attribute referencing that factory. To a consuming container the result is an ordinary +/// module factory registration, so no cross-assembly ABI beyond the existing attributes is introduced. +/// +internal sealed record ModuleScanModel( + string? Namespace, + EquatableArray ContainingTypes, + string TypeName, + string HintName, + EquatableArray Factories, + EquatableArray Diagnostics); + +/// +/// One generated module-scan factory: the emitted method name, the accessible exposure interface it returns +/// (also the service the registration exposes), the concrete implementation it constructs, the lifetime the +/// scan declared, and the constructor parameters mirrored onto the factory signature (resolved from the +/// consuming container's graph, exactly like a hand-written module factory's parameters). +/// +internal sealed record ModuleFactory( + string FactoryName, + string ServiceType, + string ImplementationType, + Lifetime Lifetime, + EquatableArray Parameters); + +/// A mirrored constructor parameter on a generated factory: its fully-qualified type and its name. +internal readonly record struct FactoryParameter(string Type, string Name); diff --git a/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs b/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs new file mode 100644 index 0000000..a20210e --- /dev/null +++ b/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs @@ -0,0 +1,108 @@ +using System.Text; +using Awaiten.SourceGenerators.Entities; + +namespace Awaiten.SourceGenerators; + +/// +/// Emits the generated partial of a [Module] that self-compiles its [Scan]: the module class is +/// re-opened as partial, carrying one lifetime registration attribute per match and one +/// public static factory method that constructs the match and returns its accessible exposure interface. +/// The factory body news the (possibly internal) implementation - legal because this code compiles +/// in the module's own assembly - while the consumer only ever names the interface and calls the factory. +/// +internal static partial class Sources +{ + public static string EmitModule(ModuleScanModel model) + { + StringBuilder builder = new(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + + int depth = 0; + bool hasNamespace = !string.IsNullOrEmpty(model.Namespace); + if (hasNamespace) + { + builder.Append("namespace ").AppendLine(model.Namespace); + builder.AppendLine("{"); + depth++; + } + + foreach (TypeDeclaration containing in model.ContainingTypes.AsArray()) + { + Indent(builder, depth).Append("partial ").Append(containing.Keyword).Append(' ').AppendLine(containing.Name); + Indent(builder, depth).AppendLine("{"); + depth++; + } + + // One registration attribute per generated factory, applied to the re-opened partial module. The consuming + // container reads these exactly like hand-written [Singleton<…>(Factory = …)] registrations. + foreach (ModuleFactory factory in model.Factories.AsArray()) + { + Indent(builder, depth).Append('[').Append(RegistrationAttribute(factory)).AppendLine("]"); + } + + Indent(builder, depth).Append("static partial class ").AppendLine(model.TypeName); + Indent(builder, depth).AppendLine("{"); + + bool first = true; + foreach (ModuleFactory factory in model.Factories.AsArray()) + { + if (!first) + { + builder.AppendLine(); + } + + first = false; + EmitModuleFactory(builder, depth + 1, factory); + } + + Indent(builder, depth).AppendLine("}"); + + for (int i = 0; i < model.ContainingTypes.Count; i++) + { + depth--; + Indent(builder, depth).AppendLine("}"); + } + + if (hasNamespace) + { + builder.AppendLine("}"); + } + + return builder.ToString(); + } + + /// + /// The single-argument lifetime registration attribute for one factory: the service is the accessible + /// exposure interface, produced by the generated factory, and Fallback.Silent makes it an overridable + /// default so a consuming container can replace it with its own registration without a conflict. + /// + private static string RegistrationAttribute(ModuleFactory factory) + { + string attribute = factory.Lifetime switch + { + Lifetime.Singleton => "SingletonAttribute", + Lifetime.Scoped => "ScopedAttribute", + _ => "TransientAttribute", + }; + + return $"global::Awaiten.{attribute}<{factory.ServiceType}>(Factory = \"{factory.FactoryName}\", Fallback = global::Awaiten.Fallback.Silent)"; + } + + private static void EmitModuleFactory(StringBuilder builder, int depth, ModuleFactory factory) + { + AppendXmlSummary(builder, depth, + "Generated from a module [Scan]: constructs the scanned implementation and exposes it through its", + "accessible interface, so a consuming container resolves it without naming the implementation."); + + FactoryParameter[] parameters = factory.Parameters.AsArray(); + string signature = string.Join(", ", parameters.Select(parameter => $"{parameter.Type} {parameter.Name}")); + string arguments = string.Join(", ", parameters.Select(parameter => parameter.Name)); + + Indent(builder, depth) + .Append("public static ").Append(factory.ServiceType).Append(' ').Append(factory.FactoryName) + .Append('(').Append(signature).Append(") => new ").Append(factory.ImplementationType) + .Append('(').Append(arguments).AppendLine(");"); + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt154ScanOnModule.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt154ScanOnModule.cs deleted file mode 100644 index 9c9c821..0000000 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt154ScanOnModule.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System.Linq; - -namespace Awaiten.SourceGenerators.Tests; - -public partial class DiagnosticTests -{ - public class Awt154ScanOnModule - { - [Fact] - public async Task ReportsWhenAnImportedModuleDeclaresAScan() - { - GeneratorResult result = Generator.Run(""" - using Awaiten; - - namespace MyCode; - - public interface IPlugin { } - public sealed class AlphaPlugin : IPlugin { } - public sealed class Logger { } - - [Module] - [Scan(typeof(IPlugin), Lifetime = AwaitenLifetime.Singleton)] - [Singleton] - public static class PluginModule { } - - [Container] - [Import(typeof(PluginModule))] - public static partial class MyContainer - { - } - """); - - await That(result.Diagnostics).Contains("*AWT154*PluginModule*").AsWildcard() - .Because("a [Scan] is not collected from modules, so it would be silently dropped without the error"); - } - - [Fact] - public async Task StillImportsTheModulesLifetimeRegistrations_AndDoesNotAlsoReportAwt151() - { - GeneratorResult result = Generator.Run(""" - using Awaiten; - - namespace MyCode; - - public interface IPlugin { } - public sealed class AlphaPlugin : IPlugin { } - public sealed class Logger { } - public sealed class Consumer - { - public Consumer(Logger logger) { } - } - - [Module] - [Scan(typeof(IPlugin), Lifetime = AwaitenLifetime.Singleton)] - [Singleton] - public static class PluginModule { } - - [Container] - [Import(typeof(PluginModule))] - [Singleton] - public static partial class MyContainer - { - } - """); - - await That(result.Diagnostics).DoesNotContain("*AWT101*").AsWildcard() - .Because("the module's lifetime registrations are still imported"); - await That(result.Diagnostics).DoesNotContain("*AWT151*").AsWildcard() - .Because("the module declares a lifetime registration, so it is not empty"); - } - - [Fact] - public async Task ReportsAlongsideAwt151ForAScanOnlyModule() - { - GeneratorResult result = Generator.Run(""" - using Awaiten; - - namespace MyCode; - - public interface IPlugin { } - public sealed class AlphaPlugin : IPlugin { } - - [Module] - [Scan(typeof(IPlugin), Lifetime = AwaitenLifetime.Singleton)] - public static class PluginModule { } - - [Container] - [Import(typeof(PluginModule))] - public static partial class MyContainer - { - } - """); - - // The scan is rejected (AWT154) and does not count as a contribution, so the module is also empty (AWT151). - await That(result.Diagnostics).Contains("*AWT154*PluginModule*").AsWildcard(); - await That(result.Diagnostics).Contains("*AWT151*PluginModule*").AsWildcard(); - } - - [Fact] - public async Task DoesNotReportForAScanOnTheContainerItself() - { - GeneratorResult result = Generator.Run(""" - using Awaiten; - - namespace MyCode; - - public interface IPlugin { } - public sealed class AlphaPlugin : IPlugin { } - public sealed class Logger { } - - [Module] - [Singleton] - public static class LoggingModule { } - - [Container] - [Import(typeof(LoggingModule))] - [Scan(typeof(IPlugin), Lifetime = AwaitenLifetime.Singleton)] - public static partial class MyContainer - { - } - """); - - await That(result.Diagnostics).DoesNotContain("*AWT154*").AsWildcard() - .Because("[Scan] on the container is the supported placement"); - } - } -} diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt194ModuleScanNotPartial.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt194ModuleScanNotPartial.cs new file mode 100644 index 0000000..e37a1d1 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt194ModuleScanNotPartial.cs @@ -0,0 +1,70 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt194ModuleScanNotPartial + { + [Fact] + public async Task ReportsWhenAModuleWithAScanIsNotPartial() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT194*PluginModule*").AsWildcard() + .Because("the module declares a [Scan] but is not partial, so the generated factories cannot be added"); + } + + [Fact] + public async Task DoesNotReportForAPartialModuleWithAScan() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT194*").AsWildcard() + .Because("a partial module can receive the generated factories"); + } + + [Fact] + public async Task DoesNotReportForAPlainModuleWithoutAScan() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public sealed class Logger { } + + [Module] + [Singleton] + public static class LoggingModule { } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT194*").AsWildcard() + .Because("a module without a [Scan] self-compiles nothing, so it need not be partial"); + } + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt195Awt196Awt197ModuleScanExposure.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt195Awt196Awt197ModuleScanExposure.cs new file mode 100644 index 0000000..6b31def --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt195Awt196Awt197ModuleScanExposure.cs @@ -0,0 +1,98 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt195Awt196Awt197ModuleScanExposure + { + [Fact] + public async Task Awt195_ReportsWhenAMatchConstructorParameterIsInaccessible() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Secret { } + + internal sealed class Roaster : IPlugin, IRoaster + { + public Roaster(Secret secret) { } + } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT195*Roaster*Secret*").AsWildcard() + .Because("the generated factory's parameter type must be nameable by a consumer, and Secret is internal"); + } + + [Fact] + public async Task Awt196_ReportsWhenNoExposureIsAccessible() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + internal sealed class Roaster : IPlugin { } + + [Module] + [Scan(As = ScanAs.Self)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT196*Roaster*").AsWildcard() + .Because("a Self exposure of an internal implementation gives a consumer no accessible type to resolve"); + } + + [Fact] + public async Task Awt197_ReportsWhenAMatchWouldHaveMultipleExposures() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + [Module] + [Scan(As = ScanAs.Marker | ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT197*Roaster*").AsWildcard() + .Because("a self-compiled match is reached through a single-interface factory, so multiple exposures are unsupported in v1"); + } + + [Fact] + public async Task DoesNotReportForASingleAccessibleExposure() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT195*").AsWildcard(); + await That(result.Diagnostics).DoesNotContain("*AWT196*").AsWildcard(); + await That(result.Diagnostics).DoesNotContain("*AWT197*").AsWildcard(); + } + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs b/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs new file mode 100644 index 0000000..04d50ae --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs @@ -0,0 +1,151 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +/// +/// A [Module] that compiles its own [Scan]: the module's build emits a factory + registration per +/// match, so a consuming container resolves an internal implementation through its accessible interface +/// without ever naming the implementation. The library keeps implementations internal and exposes only interfaces. +/// +public class SelfCompiledModuleScanTests +{ + private const string LibrarySource = """ + using Awaiten; + + namespace Lib; + + public interface IClock { } + public sealed class SystemClock : IClock { } + + public interface IPlugin { } + public interface IRoaster { } + + internal sealed class Roaster : IPlugin, IRoaster + { + public Roaster(IClock clock) { } + } + + [Module] + [Scan(As = ScanAs.MatchingInterface, Lifetime = AwaitenLifetime.Singleton)] + public static partial class PluginModule { } + """; + + [Fact] + public async Task ModuleSelfCompilesItsScanIntoAFactory() + { + (_, Microsoft.CodeAnalysis.GeneratorDriverRunResult run) = + Generator.RunGenerator(LibrarySource, [], []); + string module = run.Results + .SelectMany(r => r.GeneratedSources) + .Single(s => s.HintName.Contains("ModuleScan")) + .SourceText.ToString(); + + await That(module).Contains("static partial class PluginModule") + .Because("the module is re-opened as partial to receive the generated factory"); + await That(module).Contains("global::Awaiten.SingletonAttribute") + .Because("the match is registered under its accessible MatchingInterface, as a singleton"); + await That(module).Contains("public static global::Lib.IRoaster Awaiten__Scan_0_Roaster(global::Lib.IClock clock) => new global::Lib.Roaster(clock);") + .Because("the factory returns the interface but constructs the internal implementation in the library"); + await That(module).Contains("Fallback = global::Awaiten.Fallback.Silent") + .Because("the generated registration is an overridable default a consumer can replace"); + } + + [Fact] + public async Task ConsumerResolvesTheInternalImplementationThroughItsInterface() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(LibrarySource, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Import(typeof(PluginModule))] + [Singleton] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("the internal implementation resolves through its interface with no missing dependency"); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + + await That(source).Contains("global::Lib.PluginModule.Awaiten__Scan_0_Roaster") + .Because("the container calls the module's generated factory to build the implementation"); + await That(source).DoesNotContain("new global::Lib.Roaster") + .Because("the consumer never constructs the internal implementation directly"); + await That(result.Diagnostics).DoesNotContain("*AWT154*").AsWildcard() + .Because("a [Scan] on a module is now supported, not rejected"); + } + + [Fact] + public async Task ConsumerOverridesTheGeneratedRegistration() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(LibrarySource, """ + using Awaiten; + using Lib; + + namespace MyCode; + + public sealed class AppRoaster : IRoaster { } + + [Container] + [Import(typeof(PluginModule))] + [Singleton] + [Singleton] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("the container's own IRoaster registration overrides the module's Fallback.Silent default without a conflict"); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains("global::MyCode.AppRoaster") + .Because("the container's own registration wins the IRoaster slot"); + } + + [Fact] + public async Task InternalDisposableImplementationIsDisposedThroughTheRuntimeCheck() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(""" + using System; + using Awaiten; + + namespace Lib; + + public interface IClock { } + public sealed class SystemClock : IClock { } + public interface IPlugin { } + public interface IRoaster { } + + internal sealed class Roaster : IPlugin, IRoaster, IDisposable + { + public Roaster(IClock clock) { } + public void Dispose() { } + } + + [Module] + [Scan(As = ScanAs.MatchingInterface, Lifetime = AwaitenLifetime.Singleton)] + public static partial class PluginModule { } + """, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Import(typeof(PluginModule))] + [Singleton] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty(); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains("global::System.IDisposable") + .Because("a factory returning an interface tracks disposal behind a runtime IDisposable check, so the internal disposable is disposed"); + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/TestHelpers/Generator.cs b/Tests/Awaiten.SourceGenerators.Tests/TestHelpers/Generator.cs index a8a1ed3..f8fc1f8 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/TestHelpers/Generator.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/TestHelpers/Generator.cs @@ -40,6 +40,20 @@ public static GeneratorResult RunWithReferencedAssembly( return Run(source, [EmitToReference(referenced),]); } + /// + /// Like , but runs the generator over + /// first, so the referenced assembly contains the generated code + /// (for example a [Module] that self-compiles its [Scan] into factory methods and registration + /// attributes). Then runs the generator over against that assembly. + /// + public static GeneratorResult RunWithGeneratedReferencedAssembly( + [StringSyntax("c#-test")] string referencedSource, + [StringSyntax("c#-test")] string source) + { + (Compilation referencedOutput, _) = RunGenerator(referencedSource, [], [], "ReferencedAssembly"); + return Run(source, [EmitToReference(referencedOutput),]); + } + private static GeneratorResult Run( [StringSyntax("c#-test")] string source, MetadataReference[] additionalReferences, From 843088e3ce25bac3b05613495a5cea34d013b434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 17 Jul 2026 17:05:16 +0200 Subject: [PATCH 2/5] feat: let a module [Scan] collect and override like a container scan 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 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 to the public surface (the one new cross-assembly signal this requires). --- Docs/pages/registration/08-modules.md | 6 +- .../AwaitenGenerator.Collection.cs | 72 ++++++++++++++++-- .../AwaitenGenerator.ModuleScan.cs | 7 +- .../Entities/ModuleScanModel.cs | 7 +- .../Sources/Sources.Module.cs | 25 +++---- .../GeneratedScanRegistrationAttribute.cs | 43 +++++++++++ .../Expected/Awaiten_net10.0.txt | 8 ++ .../Expected/Awaiten_net8.0.txt | 8 ++ .../Expected/Awaiten_netstandard2.0.txt | 8 ++ .../SelfCompiledModuleScanTests.cs | 75 +++++++++++++++++-- 10 files changed, 224 insertions(+), 35 deletions(-) create mode 100644 Source/Awaiten/GeneratedScanRegistrationAttribute.cs diff --git a/Docs/pages/registration/08-modules.md b/Docs/pages/registration/08-modules.md index 49c855c..2bc2e98 100644 --- a/Docs/pages/registration/08-modules.md +++ b/Docs/pages/registration/08-modules.md @@ -56,7 +56,7 @@ public static class ProductionModule ## Self-compiled scans -A library often keeps its implementations `internal` and exposes only interfaces. A consuming container cannot construct an inaccessible type, so it could never register one — unless the library hand-wrote a factory per type. A `[Scan]` on a `[Module]` closes that gap: the module compiles its own scan **in its own build**, emitting a factory per match that constructs the implementation (which its own assembly can see) and returns the accessible interface. To the consumer this is an ordinary module factory registration, so nothing new crosses the assembly boundary. +A library often keeps its implementations `internal` and exposes only interfaces. A consuming container cannot construct an inaccessible type, so it could never register one — unless the library hand-wrote a factory per type. A `[Scan]` on a `[Module]` closes that gap: the module compiles its own scan **in its own build**, emitting a factory per match that constructs the implementation (which its own assembly can see) and returns the accessible interface. A consuming container reads each match like a container [`[Scan]`](./scanning) match, so a self-compiled scan behaves as close to a container scan as the assembly boundary allows. ```csharp public interface IClock; @@ -68,9 +68,9 @@ internal sealed class Roaster(IClock clock) : IPlugin, IRoaster; // stays inte public static partial class PluginModule; // partial, so the generator can add the factory ``` -A consuming container `[Import]`s the module and resolves `IRoaster` without ever naming `Roaster`. The generated registration is an overridable default (`Fallback.Silent`), so the container can replace it with its own registration. Because diagnostics are reported in the *library's* build, the library author — not the consumer — sees any problem. +A consuming container `[Import]`s the module and resolves `IRoaster` without ever naming `Roaster`. Each match is registered like a container scan match: an explicit registration of the same service in the container (or another module) **overrides** it, and when several matches expose the **same** interface they **collect** — a `[Scan(As = ScanAs.Marker)]` over two internal plug-ins resolves as `IEnumerable`, exactly as it would on a container. Because diagnostics are reported in the *library's* build, the library author — not the consumer — sees any problem. -The module must be `partial` ([AWT194](../diagnostics#awt194)). A few v1 limitations apply, each reported at the library's source: a match exposes through exactly one accessible interface ([AWT196](../diagnostics#awt196)/[AWT197](../diagnostics#awt197)), and its constructor parameters must be types a consumer can name ([AWT195](../diagnostics#awt195)). Self-compilation is a cross-assembly feature: within a single assembly the container can already `[Scan]` its own `internal` types directly. +The module must be `partial` ([AWT194](../diagnostics#awt194)). A few v1 limitations apply, each reported at the library's source: a single match exposes through exactly one accessible interface ([AWT196](../diagnostics#awt196)/[AWT197](../diagnostics#awt197)) — several matches under one interface still collect, but one match cannot share a single instance across several interfaces the way a container scan can — and a match's constructor parameters must be types a consumer can name ([AWT195](../diagnostics#awt195)). Self-compilation is a cross-assembly feature: within a single assembly the container can already `[Scan]` its own `internal` types directly. ## One level deep diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs index f39dee2..7fd275c 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs @@ -49,11 +49,13 @@ private static (List Raw, HashSet ConstraintRejectedSer } // ...then moved back to the end: coalescing is first-wins per service, so the explicit registrations and - // the closed registrations expanded from them must precede the overridable scan ones. - if (scans.Count > 0) + // the closed registrations expanded from them must precede the overridable scan ones — the container's own + // [Scan] matches and a module's self-compiled [GeneratedScanRegistration] matches alike, both IsScan. + List scanMatches = result.FindAll(registration => registration.IsScan); + if (scanMatches.Count > 0) { result.RemoveAll(registration => registration.IsScan); - result.AddRange(scans); + result.AddRange(scanMatches); } return (result, constraintRejected); @@ -83,6 +85,15 @@ private static void CollectLifetimeRegistrations( continue; } + // A [GeneratedScanRegistration(factory, Lifetime = …)] is what a [Module] self-compiled from + // its own [Scan] (one per match). Read it back like a container [Scan] match rather than a hand-written + // factory registration, so several matches under one interface collect instead of colliding. + if (attributeClass is { Name: "GeneratedScanRegistrationAttribute", IsGenericType: true, }) + { + CollectGeneratedScanRegistration(attribute, attributeClass, result, origin, fallbackLocation); + continue; + } + Lifetime? lifetime = attributeClass.Name switch { "SingletonAttribute" => Lifetime.Singleton, @@ -159,6 +170,56 @@ private static void CollectLifetimeRegistrations( } } + /// + /// Reads one [GeneratedScanRegistration<TService>(factory, Lifetime = …)] - what a + /// [Module] self-compiled from its own [Scan], one per match - into an IsScan factory + /// registration, so the consuming container treats it exactly like a container [Scan] match: + /// collection-eligible (several matches under one interface resolve as an IEnumerable) and overridable + /// by an explicit registration (scans rank last). Each match gets a synthetic implementation identity keyed + /// by its unique factory name, distinct from the service's own type so the emitter still constructs through + /// the accessible service; without it two matches of one interface would collapse into one implementation + /// and collide as conflicting factories. + /// + private static void CollectGeneratedScanRegistration( + AttributeData attribute, + INamedTypeSymbol attributeClass, + List result, + INamedTypeSymbol? origin, + Location? fallbackLocation) + { + if (attributeClass.TypeArguments.Length != 1 + || attributeClass.TypeArguments[0] is not INamedTypeSymbol service + || attribute.ConstructorArguments.Length != 1 + || attribute.ConstructorArguments[0].Value is not string factory) + { + return; + } + + Lifetime lifetime = Lifetime.Transient; + foreach (KeyValuePair argument in attribute.NamedArguments) + { + if (argument.Key == "Lifetime" && argument.Value.Value is int value) + { + lifetime = (Lifetime)value; + } + } + + string serviceType = service.ToDisplayString(FullyQualified); + Location? location = attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? fallbackLocation; + + result.Add(new RawRegistration( + serviceType, + $"{serviceType}@scan:{factory}", + lifetime, + service, + location, + ProductionKind.Factory, + factory, + ServiceSymbol: service, + IsScan: true, + Origin: origin)); + } + /// /// AWT168: a contextual binding is stored under a synthetic per-consumer key (see ContextKey), which /// overrides an explicit Key entirely, so the two together silently drop the Key. Report it @@ -293,7 +354,7 @@ private static bool ValidateImportedModule( /// [Singleton]/[Transient]/[Scoped] lifetime registration (generic or open /// typeof form), a [Decorate], a [Composite], or [ImportServices]. Used to /// detect a module that declares nothing to import (AWT151). A module-declared [Scan] does not - /// count here on its own: its matches are self-compiled into generated lifetime registration attributes + /// count here on its own: its matches are self-compiled into [GeneratedScanRegistration] attributes /// (which do count), so a scan-only module whose scan matched something carries those generated attributes. /// private static bool DeclaresAnyRegistration(ImmutableArray attributes) @@ -303,7 +364,8 @@ private static bool DeclaresAnyRegistration(ImmutableArray attrib if (attribute.AttributeClass is { } attributeClass && attributeClass.ContainingNamespace?.ToDisplayString() == AttributeNamespace && attributeClass.Name is "SingletonAttribute" or "TransientAttribute" or "ScopedAttribute" - or "DecorateAttribute" or "CompositeAttribute" or "ImportServicesAttribute") + or "DecorateAttribute" or "CompositeAttribute" or "ImportServicesAttribute" + or "GeneratedScanRegistrationAttribute") { return true; } diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs index c94c68c..bbacd9a 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs @@ -9,9 +9,10 @@ partial class AwaitenGenerator /// /// Expands every [Scan] declared on a [Module] into the factories the module self-compiles: /// one public static method per match that constructs the (possibly internal) implementation - /// and returns its accessible exposure interface. The emitter pairs each with a single-argument lifetime - /// registration attribute ([Singleton<IExposure>(Factory = …)]), so to a consuming container - /// the scan is indistinguishable from a hand-written module factory - no new cross-assembly ABI. Runs in the + /// and returns its accessible exposure interface. The emitter pairs each with a + /// [GeneratedScanRegistration<IExposure>(factory, Lifetime = …)], which a consuming container + /// reads like a container [Scan] match - collection-eligible (several matches under one interface + /// resolve as an IEnumerable) and overridable by an explicit registration. Runs in the /// module's own build, so diagnostics land at the library's source locations, and the scan sees the /// library's internal types (the whole point: keep implementations internal, expose interfaces). /// Reuses the container scan's candidate discovery, filters and their diagnostics diff --git a/Source/Awaiten.SourceGenerators/Entities/ModuleScanModel.cs b/Source/Awaiten.SourceGenerators/Entities/ModuleScanModel.cs index 361e04c..d9e6f3c 100644 --- a/Source/Awaiten.SourceGenerators/Entities/ModuleScanModel.cs +++ b/Source/Awaiten.SourceGenerators/Entities/ModuleScanModel.cs @@ -7,9 +7,10 @@ namespace Awaiten.SourceGenerators.Entities; /// The equatable model of a [Module] that compiles its own [Scan]: the partial class to /// re-open and the factories to emit into it, one per scan match. The generator adds a partial declaration /// carrying a generated public static factory per match (which news the — possibly -/// internal — implementation and returns its accessible exposure interface) plus a matching lifetime -/// registration attribute referencing that factory. To a consuming container the result is an ordinary -/// module factory registration, so no cross-assembly ABI beyond the existing attributes is introduced. +/// internal — implementation and returns its accessible exposure interface) plus a +/// [GeneratedScanRegistration] referencing that factory. A consuming container reads that attribute +/// like a container [Scan] match, so the matches collect and are overridable, as close to a container +/// scan as the assembly boundary allows. /// internal sealed record ModuleScanModel( string? Namespace, diff --git a/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs b/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs index a20210e..edb7c26 100644 --- a/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs +++ b/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs @@ -5,7 +5,7 @@ namespace Awaiten.SourceGenerators; /// /// Emits the generated partial of a [Module] that self-compiles its [Scan]: the module class is -/// re-opened as partial, carrying one lifetime registration attribute per match and one +/// re-opened as partial, carrying one [GeneratedScanRegistration] per match and one /// public static factory method that constructs the match and returns its accessible exposure interface. /// The factory body news the (possibly internal) implementation - legal because this code compiles /// in the module's own assembly - while the consumer only ever names the interface and calls the factory. @@ -35,8 +35,8 @@ public static string EmitModule(ModuleScanModel model) depth++; } - // One registration attribute per generated factory, applied to the re-opened partial module. The consuming - // container reads these exactly like hand-written [Singleton<…>(Factory = …)] registrations. + // One [GeneratedScanRegistration] per generated factory, applied to the re-opened partial module. The + // consuming container reads these like a container [Scan]'s matches (collection-eligible and overridable). foreach (ModuleFactory factory in model.Factories.AsArray()) { Indent(builder, depth).Append('[').Append(RegistrationAttribute(factory)).AppendLine("]"); @@ -74,21 +74,14 @@ public static string EmitModule(ModuleScanModel model) } /// - /// The single-argument lifetime registration attribute for one factory: the service is the accessible - /// exposure interface, produced by the generated factory, and Fallback.Silent makes it an overridable - /// default so a consuming container can replace it with its own registration without a conflict. + /// The registration attribute for one factory: the service is the accessible exposure interface the + /// generated factory produces, and the lifetime is the one the scan declared. A consuming container reads + /// [GeneratedScanRegistration] like a container [Scan]'s match - collection-eligible so + /// several matches under one interface resolve as an IEnumerable, and overridable by an explicit + /// registration - so a self-compiled scan behaves as close to a container scan as the assembly boundary allows. /// private static string RegistrationAttribute(ModuleFactory factory) - { - string attribute = factory.Lifetime switch - { - Lifetime.Singleton => "SingletonAttribute", - Lifetime.Scoped => "ScopedAttribute", - _ => "TransientAttribute", - }; - - return $"global::Awaiten.{attribute}<{factory.ServiceType}>(Factory = \"{factory.FactoryName}\", Fallback = global::Awaiten.Fallback.Silent)"; - } + => $"global::Awaiten.GeneratedScanRegistrationAttribute<{factory.ServiceType}>(\"{factory.FactoryName}\", Lifetime = global::Awaiten.AwaitenLifetime.{factory.Lifetime})"; private static void EmitModuleFactory(StringBuilder builder, int depth, ModuleFactory factory) { diff --git a/Source/Awaiten/GeneratedScanRegistrationAttribute.cs b/Source/Awaiten/GeneratedScanRegistrationAttribute.cs new file mode 100644 index 0000000..f572eb0 --- /dev/null +++ b/Source/Awaiten/GeneratedScanRegistrationAttribute.cs @@ -0,0 +1,43 @@ +using System; +using System.ComponentModel; + +namespace Awaiten; + +// S2326: the type parameter is the source generator's input. It reads the service type from the attribute's +// type argument via Roslyn, so the body never references it. +#pragma warning disable S2326 + +/// +/// Emitted by the source generator onto a [Module] that self-compiles a [Scan] - one per +/// match - and read back by a consuming container. It carries the accessible exposure interface +/// (), the name of the generated public static factory that +/// constructs the (possibly internal) match and returns that interface, and the lifetime the scan +/// declared. A container reads these like a container [Scan]'s matches - collection-eligible and +/// overridable by an explicit registration - so a self-compiled scan behaves as close to a container scan +/// as the assembly boundary allows. +/// +/// The accessible interface the match is exposed and resolved under. +/// Generated code, not intended to be written by hand. +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class GeneratedScanRegistrationAttribute : Attribute + where TService : class +{ + /// Registers one scan match produced by . + /// + /// The name of the generated static factory method on the module that constructs the match and + /// returns ; its parameters are resolved from the consumer's graph. + /// + public GeneratedScanRegistrationAttribute(string factory) + { + Factory = factory; + } + + /// The generated factory method that constructs the match. + public string Factory { get; } + + /// The lifetime the scan declared. Defaults to , matching [Scan]. + public AwaitenLifetime Lifetime { get; set; } = AwaitenLifetime.Transient; +} + +#pragma warning restore S2326 diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt index a5d4104..61aa9b3 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt @@ -110,6 +110,14 @@ namespace Awaiten public FromKeyAttribute(string key) { } public object Key { get; } } + [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] + public sealed class GeneratedScanRegistrationAttribute : System.Attribute + where TService : class + { + public GeneratedScanRegistrationAttribute(string factory) { } + public string Factory { get; } + public Awaiten.AwaitenLifetime Lifetime { get; set; } + } public interface IAsyncInitializable { System.Threading.Tasks.Task InitializeAsync(System.Threading.CancellationToken cancellationToken); diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt index 431ad89..1cd152d 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt @@ -110,6 +110,14 @@ namespace Awaiten public FromKeyAttribute(string key) { } public object Key { get; } } + [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] + public sealed class GeneratedScanRegistrationAttribute : System.Attribute + where TService : class + { + public GeneratedScanRegistrationAttribute(string factory) { } + public string Factory { get; } + public Awaiten.AwaitenLifetime Lifetime { get; set; } + } public interface IAsyncInitializable { System.Threading.Tasks.Task InitializeAsync(System.Threading.CancellationToken cancellationToken); diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt index a8c5117..d9dc704 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt @@ -110,6 +110,14 @@ namespace Awaiten public FromKeyAttribute(string key) { } public object Key { get; } } + [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] + public sealed class GeneratedScanRegistrationAttribute : System.Attribute + where TService : class + { + public GeneratedScanRegistrationAttribute(string factory) { } + public string Factory { get; } + public Awaiten.AwaitenLifetime Lifetime { get; set; } + } public interface IAsyncInitializable { System.Threading.Tasks.Task InitializeAsync(System.Threading.CancellationToken cancellationToken); diff --git a/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs b/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs index 04d50ae..6ed6f5c 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs @@ -42,12 +42,10 @@ public async Task ModuleSelfCompilesItsScanIntoAFactory() await That(module).Contains("static partial class PluginModule") .Because("the module is re-opened as partial to receive the generated factory"); - await That(module).Contains("global::Awaiten.SingletonAttribute") - .Because("the match is registered under its accessible MatchingInterface, as a singleton"); + await That(module).Contains("global::Awaiten.GeneratedScanRegistrationAttribute(\"Awaiten__Scan_0_Roaster\", Lifetime = global::Awaiten.AwaitenLifetime.Singleton)") + .Because("the match is registered under its accessible MatchingInterface, as a singleton scan match"); await That(module).Contains("public static global::Lib.IRoaster Awaiten__Scan_0_Roaster(global::Lib.IClock clock) => new global::Lib.Roaster(clock);") .Because("the factory returns the interface but constructs the internal implementation in the library"); - await That(module).Contains("Fallback = global::Awaiten.Fallback.Silent") - .Because("the generated registration is an overridable default a consumer can replace"); } [Fact] @@ -100,7 +98,7 @@ public static partial class MyContainer """); await That(result.Diagnostics).IsEmpty() - .Because("the container's own IRoaster registration overrides the module's Fallback.Silent default without a conflict"); + .Because("an explicit registration outranks a scan match, so the container's own IRoaster wins without a conflict"); string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; await That(source).Contains("global::MyCode.AppRoaster") .Because("the container's own registration wins the IRoaster slot"); @@ -148,4 +146,71 @@ public static partial class MyContainer await That(source).Contains("global::System.IDisposable") .Because("a factory returning an interface tracks disposal behind a runtime IDisposable check, so the internal disposable is disposed"); } + + private const string PluginFamilySource = """ + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + + internal sealed class Alpha : IPlugin { } + internal sealed class Bravo : IPlugin { } + + [Module] + [Scan(As = ScanAs.Marker, Lifetime = AwaitenLifetime.Singleton)] + public static partial class PluginModule { } + """; + + [Fact] + public async Task MultipleMatchesUnderOneInterfaceResolveAsACollection() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(PluginFamilySource, """ + using System.Collections.Generic; + using Awaiten; + using Lib; + + namespace MyCode; + + public sealed class Host + { + public Host(IEnumerable plugins) { } + } + + [Container] + [Import(typeof(PluginModule))] + [Singleton] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("two internal implementations of one marker interface collect instead of colliding (no AWT111)"); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains("global::Lib.PluginModule.Awaiten__Scan_0_Alpha") + .Because("the first match is a member of the IEnumerable collection"); + await That(source).Contains("global::Lib.PluginModule.Awaiten__Scan_1_Bravo") + .Because("the second match is a member of the IEnumerable collection too, exactly as a container scan would collect them"); + } + + [Fact] + public async Task MultipleMatchesUnderOneInterfaceDoNotConflict() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(PluginFamilySource, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Import(typeof(PluginModule))] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT111*").AsWildcard() + .Because("self-compiled scan matches are collection-eligible, not conflicting single-service factories"); + } } From bd337ed285404cb8cd1ac8dc28644150cb8da1ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 18 Jul 2026 08:43:57 +0200 Subject: [PATCH 3/5] fix: qualify scan-match identity by module and expand same-compilation 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. --- Docs/pages/registration/08-modules.md | 6 +- .../AwaitenGenerator.Collection.cs | 50 +++++++--- .../SelfCompiledModuleScanTests.cs | 92 +++++++++++++++++++ 3 files changed, 134 insertions(+), 14 deletions(-) diff --git a/Docs/pages/registration/08-modules.md b/Docs/pages/registration/08-modules.md index 2bc2e98..7a764fb 100644 --- a/Docs/pages/registration/08-modules.md +++ b/Docs/pages/registration/08-modules.md @@ -70,11 +70,13 @@ public static partial class PluginModule; // partial, so the generator can add A consuming container `[Import]`s the module and resolves `IRoaster` without ever naming `Roaster`. Each match is registered like a container scan match: an explicit registration of the same service in the container (or another module) **overrides** it, and when several matches expose the **same** interface they **collect** — a `[Scan(As = ScanAs.Marker)]` over two internal plug-ins resolves as `IEnumerable`, exactly as it would on a container. Because diagnostics are reported in the *library's* build, the library author — not the consumer — sees any problem. -The module must be `partial` ([AWT194](../diagnostics#awt194)). A few v1 limitations apply, each reported at the library's source: a single match exposes through exactly one accessible interface ([AWT196](../diagnostics#awt196)/[AWT197](../diagnostics#awt197)) — several matches under one interface still collect, but one match cannot share a single instance across several interfaces the way a container scan can — and a match's constructor parameters must be types a consumer can name ([AWT195](../diagnostics#awt195)). Self-compilation is a cross-assembly feature: within a single assembly the container can already `[Scan]` its own `internal` types directly. +The module must be `partial` ([AWT194](../diagnostics#awt194)). A few v1 limitations apply, each reported at the library's source: a single match exposes through exactly one accessible interface ([AWT196](../diagnostics#awt196)/[AWT197](../diagnostics#awt197)) — several matches under one interface still collect, but one match cannot share a single instance across several interfaces the way a container scan can — and a match's constructor parameters must be types a consumer can name ([AWT195](../diagnostics#awt195)). + +When the module lives in the **same assembly as the container**, there is no assembly boundary to bridge: the container expands the module's `[Scan]` directly, exactly as if it were declared on the container itself, with full container-scan semantics (multi-interface exposure, `SkipUnconstructable`, direct construction of `internal` matches). The self-compiled factories are still generated into the module, so the same module keeps working for any *other* assembly that imports it; but note that the v1 limitations above are checked in the module's build regardless, since the module cannot know whether a cross-assembly consumer exists. ## One level deep -Imports are not transitive. A module's own `[Import]` is not followed. Keep the container as the single place that composes modules. (A module's own `[Scan]`, by contrast, is self-compiled in the module's build — see above — not collected by the importing container.) +Imports are not transitive. A module's own `[Import]` is not followed. Keep the container as the single place that composes modules. (A module's own `[Scan]`, by contrast, does reach the importing container: expanded directly by the container for a same-assembly module, or self-compiled in the module's build across assemblies — see above.) *Note: importing a type that is not a `[Module]` is an error ([AWT149](../diagnostics#awt149)), and a module must be static ([AWT152](../diagnostics#awt152)).* diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs index 7fd275c..674310d 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs @@ -40,6 +40,20 @@ private static (List Raw, HashSet ConstraintRejectedSer List scans = CollectScans(containerSymbol, compilation, diagnostics); result.AddRange(scans); + // A same-compilation module's [Scan] is expanded here, exactly as if it were declared on the container: + // the generator cannot see its own output, so the module's self-compiled [GeneratedScanRegistration] + // attributes are invisible within the compilation that declares the module — but there is no assembly + // boundary either, so the container can evaluate the scan directly, with full container-scan semantics. + // A referenced-assembly module is excluded: its metadata carries the self-compiled expansion instead, and + // re-running its scan here would double-register every match. + foreach (ImportedModule module in modules) + { + if (SymbolEqualityComparer.Default.Equals(module.Symbol.ContainingAssembly, containerSymbol.ContainingAssembly)) + { + result.AddRange(CollectScans(module.Symbol, compilation, diagnostics)); + } + } + // Expand open generic registrations: for every closed generic service required from the graph // whose open form is registered but which has no concrete registration, synthesize the closed // implementation (iterating to a fixpoint over its own generic dependencies). @@ -176,9 +190,11 @@ private static void CollectLifetimeRegistrations( /// registration, so the consuming container treats it exactly like a container [Scan] match: /// collection-eligible (several matches under one interface resolve as an IEnumerable) and overridable /// by an explicit registration (scans rank last). Each match gets a synthetic implementation identity keyed - /// by its unique factory name, distinct from the service's own type so the emitter still constructs through - /// the accessible service; without it two matches of one interface would collapse into one implementation - /// and collide as conflicting factories. + /// by its module-qualified factory name, distinct from the service's own type so the emitter still constructs + /// through the accessible service; without it two matches of one interface would collapse into one + /// implementation and collide as conflicting factories. The module qualification matters: factory names are + /// only unique per module, so two imported modules whose matches share a simple type name would otherwise + /// collapse into one member, silently dropping the later module's match from the collection. /// private static void CollectGeneratedScanRegistration( AttributeData attribute, @@ -206,10 +222,11 @@ private static void CollectGeneratedScanRegistration( string serviceType = service.ToDisplayString(FullyQualified); Location? location = attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? fallbackLocation; + string qualifiedFactory = origin is null ? factory : $"{origin.ToDisplayString(FullyQualified)}.{factory}"; result.Add(new RawRegistration( serviceType, - $"{serviceType}@scan:{factory}", + $"{serviceType}@scan:{qualifiedFactory}", lifetime, service, location, @@ -284,7 +301,8 @@ private static List CollectImportedModules(INamedTypeSymbol cont // not the module declaration, which may live in another file (or, for a module compiled into a // referenced assembly, in no source at all). Location? importLocation = attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation(); - if (ValidateImportedModule(module, importLocation, diagnostics)) + bool sameCompilation = SymbolEqualityComparer.Default.Equals(module.ContainingAssembly, containerSymbol.ContainingAssembly); + if (ValidateImportedModule(module, importLocation, sameCompilation, diagnostics)) { modules.Add(new ImportedModule(module, importLocation)); } @@ -298,11 +316,14 @@ private static List CollectImportedModules(INamedTypeSymbol cont /// importable. A non-[Module] target (AWT149) is skipped (); the other faults /// are reported but still imported (): non-static (AWT152), a nested [Import] /// (AWT150), or no registrations (AWT151). A module-declared [Scan] is accepted (self-compiled in the - /// module's own build, AWT154 retired) rather than rejected. + /// module's own build, AWT154 retired) rather than rejected. marks a + /// module declared in the container's own compilation, whose [Scan] the container expands directly + /// (its self-compiled attributes are invisible here), so the scan counts as a registration for AWT151. /// private static bool ValidateImportedModule( INamedTypeSymbol module, Location? importLocation, + bool sameCompilation, List diagnostics) { LocationInfo? location = LocationInfo.From(importLocation); @@ -335,12 +356,17 @@ private static bool ValidateImportedModule( Diagnostics.NestedModuleImport, location, new EquatableArray([moduleName,]))); } - // A [Scan] on a module is self-compiled in the module's own build (the module emits a generated factory - // and lifetime registration per match, which this collection reads like any other module registration), - // so the consumer neither re-runs it nor rejects it. See ExpandModuleScan; AWT154 was retired. - - // AWT151: a module that declares no lifetime registrations imports nothing useful. - if (!DeclaresAnyRegistration(moduleAttributes)) + // A referenced-assembly module's [Scan] is self-compiled in the module's own build (the module emits a + // generated factory and lifetime registration per match, which this collection reads like any other module + // registration), so the consumer neither re-runs it nor rejects it. See ExpandModuleScan; AWT154 was + // retired. A same-compilation module's [Scan] is instead expanded by the container itself (see Collect). + + // AWT151: a module that declares no lifetime registrations imports nothing useful. A same-compilation + // module's [Scan] does contribute (the container expands it directly), so it counts here; a + // referenced-assembly module contributes through its self-compiled attributes instead, which + // DeclaresAnyRegistration already counts. + if (!DeclaresAnyRegistration(moduleAttributes) + && !(sameCompilation && HasAwaitenAttribute(moduleAttributes, "ScanAttribute"))) { diagnostics.Add(new DiagnosticInfo( Diagnostics.EmptyModule, location, new EquatableArray([moduleName,]))); diff --git a/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs b/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs index 6ed6f5c..fded9d4 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs @@ -213,4 +213,96 @@ public static partial class MyContainer await That(result.Diagnostics).DoesNotContain("*AWT111*").AsWildcard() .Because("self-compiled scan matches are collection-eligible, not conflicting single-service factories"); } + + [Fact] + public async Task TwoModulesWithSameNamedMatchesUnderOneInterfaceBothCollect() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(""" + using Awaiten; + + namespace Lib + { + public interface IPlugin { } + + [Module] + [Scan(As = ScanAs.Marker, NamespacePatterns = new[] { "Lib.A" })] + public static partial class ModuleA { } + + [Module] + [Scan(As = ScanAs.Marker, NamespacePatterns = new[] { "Lib.B" })] + public static partial class ModuleB { } + } + + namespace Lib.A + { + internal sealed class Handler : Lib.IPlugin { } + } + + namespace Lib.B + { + internal sealed class Handler : Lib.IPlugin { } + } + """, """ + using System.Collections.Generic; + using Awaiten; + using Lib; + + namespace MyCode; + + public sealed class Host + { + public Host(IEnumerable plugins) { } + } + + [Container] + [Import(typeof(ModuleA))] + [Import(typeof(ModuleB))] + [Singleton] + public static partial class MyContainer + { + } + """); + + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains("global::Lib.ModuleA.Awaiten__Scan_0_Handler") + .Because("module A's match is a member of the collection"); + await That(source).Contains("global::Lib.ModuleB.Awaiten__Scan_0_Handler") + .Because("module B's match must not be deduped away by sharing module A's per-module factory name"); + } + + [Fact] + public async Task SameCompilationModuleScanIsExpandedByTheContainer() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IClock { } + public sealed class SystemClock : IClock { } + + public interface IPlugin { } + public interface IRoaster { } + + internal sealed class Roaster : IPlugin, IRoaster + { + public Roaster(IClock clock) { } + } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + + [Container] + [Import(typeof(PluginModule))] + [Singleton] + public static partial class MyContainer { } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("a same-compilation module's [Scan] is expanded by the container itself, so nothing is dropped and AWT151 does not misfire"); + string source = result.Sources["Awaiten.Lib.MyContainer.g.cs"]; + await That(source).Contains("new global::Lib.Roaster(") + .Because("with no assembly boundary the container constructs the internal match directly, as if the [Scan] were its own"); + } } From 2e22e3d5ee385d5d8ce8b3ae156383a2eb5c7a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 18 Jul 2026 12:04:52 +0200 Subject: [PATCH 4/5] fix: close six review findings on self-compiled module scans (adds AWT200, 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. --- Docs/pages/09-diagnostics.md | 36 ++++++ Docs/pages/registration/08-modules.md | 3 +- .../AnalyzerReleases.Unshipped.md | 2 + .../AwaitenGenerator.Collection.cs | 26 ++-- .../AwaitenGenerator.ModuleScan.cs | 67 +++++++++- .../AwaitenGenerator.cs | 16 ++- .../Awaiten.SourceGenerators/Diagnostics.cs | 29 +++++ .../Sources/Sources.Module.cs | 6 +- ...Tests.Awt200Awt201ModuleScanLimitations.cs | 108 ++++++++++++++++ .../SelfCompiledModuleScanTests.cs | 116 +++++++++++++++++- 10 files changed, 388 insertions(+), 21 deletions(-) create mode 100644 Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt200Awt201ModuleScanLimitations.cs diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index 75f8ef4..62f4e71 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -1418,6 +1418,42 @@ public static partial class PluginModule; A self-compiled match is reached through a generated factory that returns a single accessible interface, so a shared instance across several interfaces cannot be expressed (unlike a container `[Scan]`, whose matches coalesce on the concrete type). Narrow the exposure to a single interface (typically `ScanAs.MatchingInterface`), or exclude the match. This is a v1 limitation. +### AWT200 + +:::danger[Error] +A self-compiled module `[Scan]` match carries injection metadata the generated factory cannot mirror. +::: + +```csharp +internal sealed class Roaster : IRoaster +{ + public Roaster(IClock clock) { } + + [Inject] // keys, optionality and deferral live on this attribute + public IGrinder Grinder { get; set; } +} + +[Module] +[Scan(As = ScanAs.MatchingInterface)] +public static partial class PluginModule; +``` + +The generated factory reduces a match to a plain parameter list resolved from the consuming container's graph. An `[Inject]` property, or a constructor parameter marked `[Inject]` or `[Arg]`, carries per-dependency semantics that plain parameters cannot express, so the consumer would silently construct the match differently than a container `[Scan]` would. Remove the attribute, exclude the match, or register the type through a hand-written module factory. + +### AWT201 + +:::danger[Error] +A generic `[Module]` (or one nested in a generic type) declares a `[Scan]`. +::: + +```csharp +[Module] +[Scan(As = ScanAs.MatchingInterface)] +public static partial class PluginModule; // no closed PluginModule exists to [Import] +``` + +A consumer imports a module by `typeof`, so there is no single closed module type to import from a generic declaration, and the generated partial could not re-open it by its bare name. Move the `[Scan]` onto a non-generic module. + ## Keyed collections ### AWT159 diff --git a/Docs/pages/registration/08-modules.md b/Docs/pages/registration/08-modules.md index 7a764fb..78fc71e 100644 --- a/Docs/pages/registration/08-modules.md +++ b/Docs/pages/registration/08-modules.md @@ -60,6 +60,7 @@ A library often keeps its implementations `internal` and exposes only interfaces ```csharp public interface IClock; +public interface IPlugin; public interface IRoaster; internal sealed class Roaster(IClock clock) : IPlugin, IRoaster; // stays internal @@ -70,7 +71,7 @@ public static partial class PluginModule; // partial, so the generator can add A consuming container `[Import]`s the module and resolves `IRoaster` without ever naming `Roaster`. Each match is registered like a container scan match: an explicit registration of the same service in the container (or another module) **overrides** it, and when several matches expose the **same** interface they **collect** — a `[Scan(As = ScanAs.Marker)]` over two internal plug-ins resolves as `IEnumerable`, exactly as it would on a container. Because diagnostics are reported in the *library's* build, the library author — not the consumer — sees any problem. -The module must be `partial` ([AWT194](../diagnostics#awt194)). A few v1 limitations apply, each reported at the library's source: a single match exposes through exactly one accessible interface ([AWT196](../diagnostics#awt196)/[AWT197](../diagnostics#awt197)) — several matches under one interface still collect, but one match cannot share a single instance across several interfaces the way a container scan can — and a match's constructor parameters must be types a consumer can name ([AWT195](../diagnostics#awt195)). +The module must be `partial` ([AWT194](../diagnostics#awt194)). A few v1 limitations apply, each reported at the library's source: a single match exposes through exactly one accessible interface ([AWT196](../diagnostics#awt196)/[AWT197](../diagnostics#awt197)) — several matches under one interface still collect, but one match cannot share a single instance across several interfaces the way a container scan can — and a match's constructor parameters must be types a consumer can name ([AWT195](../diagnostics#awt195)) without carrying `[Inject]`/`[Arg]` metadata the factory could not mirror ([AWT200](../diagnostics#awt200)). The module itself must be non-generic ([AWT201](../diagnostics#awt201)). When the module lives in the **same assembly as the container**, there is no assembly boundary to bridge: the container expands the module's `[Scan]` directly, exactly as if it were declared on the container itself, with full container-scan semantics (multi-interface exposure, `SkipUnconstructable`, direct construction of `internal` matches). The self-compiled factories are still generated into the module, so the same module keeps working for any *other* assembly that imports it; but note that the v1 limitations above are checked in the module's build regardless, since the module cannot know whether a cross-assembly consumer exists. diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index 689d48f..4441512 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -98,3 +98,5 @@ AWT195 | Awaiten | Error | A self-compiled module [Scan] match has a constructor parameter type inaccessible outside the module's assembly AWT196 | Awaiten | Error | A self-compiled module [Scan] match has no exposure interface accessible outside the module's assembly AWT197 | Awaiten | Error | A self-compiled module [Scan] match would be exposed under multiple interfaces, which a single-exposure factory cannot express + AWT200 | Awaiten | Error | A self-compiled module [Scan] match carries [Inject]/[Arg] metadata the generated factory cannot mirror + AWT201 | Awaiten | Error | A generic [Module] (or one nested in a generic type) declares a [Scan], so no closed module exists for a consumer to import diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs index 674310d..6bed87f 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs @@ -28,10 +28,14 @@ private static (List Raw, HashSet ConstraintRejectedSer // [Import(typeof(Module))] pulls a module's registrations in after the container's own, so the container // wins ties and a module's overridable defaults (Fallback.Warn/Silent) only fill the gaps it leaves. Resolved - // one level deep; a module's own [Import] is not followed. + // one level deep; a module's own [Import] is not followed. A same-compilation module's + // [GeneratedScanRegistration] attributes are skipped: the generator itself never sees them (its own output), + // but an analyzer running over the post-generation compilation does, and collecting them alongside the + // direct scan expansion below would double every match in its graph. foreach (ImportedModule module in modules) { - CollectLifetimeRegistrations(module.Symbol, result, open, diagnostics, origin: module.Symbol, fallbackLocation: module.ImportLocation); + bool sameCompilation = SymbolEqualityComparer.Default.Equals(module.Symbol.ContainingAssembly, containerSymbol.ContainingAssembly); + CollectLifetimeRegistrations(module.Symbol, result, open, diagnostics, origin: module.Symbol, fallbackLocation: module.ImportLocation, skipGeneratedScanRegistrations: sameCompilation); } // Assembly scanning contributes overridable registrations for every concrete type assignable to a @@ -45,12 +49,14 @@ private static (List Raw, HashSet ConstraintRejectedSer // attributes are invisible within the compilation that declares the module — but there is no assembly // boundary either, so the container can evaluate the scan directly, with full container-scan semantics. // A referenced-assembly module is excluded: its metadata carries the self-compiled expansion instead, and - // re-running its scan here would double-register every match. + // re-running its scan here would double-register every match. The expansion's diagnostics are discarded: + // the module pipeline (BuildModuleModel) already reports on the same [Scan] at the same location, and + // reporting here too would double every scan-level warning. foreach (ImportedModule module in modules) { if (SymbolEqualityComparer.Default.Equals(module.Symbol.ContainingAssembly, containerSymbol.ContainingAssembly)) { - result.AddRange(CollectScans(module.Symbol, compilation, diagnostics)); + result.AddRange(CollectScans(module.Symbol, compilation, new List())); } } @@ -89,7 +95,8 @@ private static void CollectLifetimeRegistrations( List open, List diagnostics, INamedTypeSymbol? origin, - Location? fallbackLocation) + Location? fallbackLocation, + bool skipGeneratedScanRegistrations = false) { foreach (AttributeData attribute in symbol.GetAttributes()) { @@ -101,10 +108,15 @@ private static void CollectLifetimeRegistrations( // A [GeneratedScanRegistration(factory, Lifetime = …)] is what a [Module] self-compiled from // its own [Scan] (one per match). Read it back like a container [Scan] match rather than a hand-written - // factory registration, so several matches under one interface collect instead of colliding. + // factory registration, so several matches under one interface collect instead of colliding. Skipped + // for a same-compilation module, whose [Scan] the container expands directly (see Collect). if (attributeClass is { Name: "GeneratedScanRegistrationAttribute", IsGenericType: true, }) { - CollectGeneratedScanRegistration(attribute, attributeClass, result, origin, fallbackLocation); + if (!skipGeneratedScanRegistrations) + { + CollectGeneratedScanRegistration(attribute, attributeClass, result, origin, fallbackLocation); + } + continue; } diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs index bbacd9a..dc4c9ec 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Awaiten.SourceGenerators.Entities; using Awaiten.SourceGenerators.Internals; using Microsoft.CodeAnalysis; @@ -16,8 +17,9 @@ partial class AwaitenGenerator /// module's own build, so diagnostics land at the library's source locations, and the scan sees the /// library's internal types (the whole point: keep implementations internal, expose interfaces). /// Reuses the container scan's candidate discovery, filters and their diagnostics - /// (AWT138/AWT140/AWT143/AWT172/AWT173/AWT174/AWT183/AWT184/AWT185), and adds AWT195/AWT196/AWT197 for the - /// self-compilation constraints (accessible parameters, a single accessible exposure per match). + /// (AWT138/AWT140/AWT143/AWT172/AWT173/AWT174/AWT183/AWT184/AWT185), and adds AWT195/AWT196/AWT197/AWT200 + /// for the self-compilation constraints (accessible parameters, a single accessible exposure per match, no + /// injection metadata the factory could not mirror). /// private static List CollectModuleScanFactories( INamedTypeSymbol module, @@ -126,8 +128,10 @@ private static void ExpandModuleScan( /// single interface a consumer in another assembly can name (AWT196 when none, AWT197 when several, since a /// factory returns one accessible type and a shared instance across interfaces cannot be expressed), and the /// constructor's parameters must all be accessible outside the assembly (AWT195), because they appear on the - /// generated public factory and are resolved from the consumer's graph. Returns the number of - /// factories contributed (0 or 1). + /// generated public factory and are resolved from the consumer's graph. A match carrying injection + /// metadata the factory cannot mirror ([Inject] properties, [Inject]/[Arg] parameters) is AWT200, and a + /// match another scan of this module already exposed the same way is deduped silently (a differing lifetime + /// across the overlap is AWT142, first scan wins). Returns the number of factories contributed (0 or 1). /// private static int RegisterModuleScanMatch( INamedTypeSymbol type, @@ -195,6 +199,45 @@ private static int RegisterModuleScanMatch( } INamedTypeSymbol service = distinct[0]; + string serviceName = service.ToDisplayString(FullyQualified); + + // Two [Scan]s on one module can match the same type under the same exposure. A container dedups such + // overlaps to one collection member (per-implementation identity); factories are per-match identities on + // the consumer, so dedup here instead, keeping the first. A differing lifetime across the overlapping + // scans is AWT142, mirroring the container, and the first scan's lifetime wins consistently. + ModuleFactory? overlapping = factories.Find(factory => factory.ImplementationType == typeName); + Lifetime lifetime = overlapping?.Lifetime ?? match.Lifetime; + if (overlapping is not null) + { + if (overlapping.Lifetime != match.Lifetime) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ScanLifetimeConflict, + LocationInfo.From(match.Location), + new EquatableArray([ + Display(typeName), + overlapping.Lifetime.ToString(), + match.Lifetime.ToString(), + ]))); + } + + if (factories.Exists(factory => factory.ImplementationType == typeName && factory.ServiceType == serviceName)) + { + return 0; + } + } + + // [Inject] properties and [Inject]/[Arg] constructor parameters carry per-dependency semantics (keys, + // optionality, deferral, resolution arguments) that a mirrored factory signature cannot express, so the + // consumer would silently construct the match differently than a container scan would (AWT200). + foreach (IPropertySymbol property in InjectedProperties(type)) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ModuleScanInjectionMetadata, + LocationInfo.From(match.Location), + new EquatableArray([Display(typeName), $"its property '{property.Name}' is marked [Inject]",]))); + return 0; + } // The constructor the generated factory calls. Accessibility is asked against the module (it emits the // 'new'), so an internal constructor in the module's own assembly qualifies. A greediest-fallback keeps a @@ -213,6 +256,18 @@ private static int RegisterModuleScanMatch( List parameters = new(); foreach (IParameterSymbol parameter in constructor.Parameters) { + // AWT200 for parameters, same reasoning as the [Inject] property check above. + ImmutableArray parameterAttributes = parameter.GetAttributes(); + if (HasInject(parameterAttributes) || HasArgAttribute(parameterAttributes)) + { + string attributeName = HasInject(parameterAttributes) ? "[Inject]" : "[Arg]"; + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ModuleScanInjectionMetadata, + LocationInfo.From(match.Location), + new EquatableArray([Display(typeName), $"its constructor parameter '{parameter.Name}' is marked {attributeName}",]))); + return 0; + } + // The parameter type appears on the public factory signature and is resolved from the consumer's graph, // so it must be nameable outside the assembly (AWT195). This is the v1 limitation on self-compiled scans. if (!IsExternallyAccessible(parameter.Type)) @@ -229,9 +284,9 @@ private static int RegisterModuleScanMatch( factories.Add(new ModuleFactory( ModuleFactoryName(type, factories.Count), - service.ToDisplayString(FullyQualified), + serviceName, typeName, - match.Lifetime, + lifetime, new EquatableArray(parameters.ToArray()))); return 1; } diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs index c7e3c8c..0a74fcf 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs @@ -77,8 +77,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) /// /// Builds the for a [Module] that declares a [Scan] (returning /// for a module without one, which self-compiles nothing). The module must be - /// partial to receive the generated factories and registration attributes (AWT194); when it is, its - /// scans are expanded into factories in its own build (see ). + /// non-generic (AWT201) and partial to receive the generated factories and registration attributes + /// (AWT194); when it is, its scans are expanded into factories in its own build (see + /// ). /// private static ModuleScanModel? BuildModuleModel(GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) { @@ -103,7 +104,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) && declaration.Modifiers.Any(SyntaxKind.PartialKeyword); List factories = new(); - if (!isPartial) + if (HasOpenTypeParameters(moduleSymbol)) + { + // AWT201: a generic module (or one nested in a generic type) has no single closed type a consumer could + // import, and re-opening it as a bare-named partial would emit an unrelated non-generic class instead. + diagnostics.Add(new DiagnosticInfo( + Diagnostics.GenericModuleScan, + LocationInfo.From(moduleSymbol.Locations.FirstOrDefault()), + new EquatableArray([Display(moduleSymbol.ToDisplayString(FullyQualified)),]))); + } + else if (!isPartial) { diagnostics.Add(new DiagnosticInfo( Diagnostics.NonPartialModuleScan, diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index eddcf8a..2b69dd2 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -1443,4 +1443,33 @@ internal static class Diagnostics "Awaiten", DiagnosticSeverity.Error, isEnabledByDefault: true); + + /// + /// A self-compiled module [Scan] match carries injection metadata the generated factory cannot + /// mirror: an [Inject] property, or a constructor parameter marked [Inject] or [Arg]. + /// The factory reduces a match to a plain parameter list resolved from the consumer's graph, so keys, + /// optionality, deferral and resolution arguments would be silently dropped, and the same type would behave + /// differently scanned by a container. Rejected rather than silently degraded. + /// + public static readonly DiagnosticDescriptor ModuleScanInjectionMetadata = new( + "AWT200", + "Module scan match uses injection metadata", + "'{0}' matched the module scan, but {1}, which the generated factory cannot mirror and would silently drop; remove the attribute, exclude the type from the scan, or register it through a hand-written module factory", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + /// + /// A generic [Module] (or one nested in a generic type) declares a [Scan]. There is no single + /// closed module type a consumer could import, and the generated partial could not even re-open the generic + /// declaration by its bare name, so the scan could never reach a consumer. Reported instead of silently + /// emitting an unrelated non-generic partial. + /// + public static readonly DiagnosticDescriptor GenericModuleScan = new( + "AWT201", + "Module with a scan is generic", + "the module '{0}' declares a [Scan] but is generic (or nested in a generic type), so no closed module exists for a consumer to import; move the [Scan] onto a non-generic module", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } diff --git a/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs b/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs index edb7c26..1faf11a 100644 --- a/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs +++ b/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs @@ -89,9 +89,11 @@ private static void EmitModuleFactory(StringBuilder builder, int depth, ModuleFa "Generated from a module [Scan]: constructs the scanned implementation and exposes it through its", "accessible interface, so a consuming container resolves it without naming the implementation."); + // Parameter names are emitted verbatim-prefixed: a scanned constructor may legally name a parameter with a + // reserved keyword ('@event'), whose symbol name carries no '@', and the prefix is harmless otherwise. FactoryParameter[] parameters = factory.Parameters.AsArray(); - string signature = string.Join(", ", parameters.Select(parameter => $"{parameter.Type} {parameter.Name}")); - string arguments = string.Join(", ", parameters.Select(parameter => parameter.Name)); + string signature = string.Join(", ", parameters.Select(parameter => $"{parameter.Type} @{parameter.Name}")); + string arguments = string.Join(", ", parameters.Select(parameter => $"@{parameter.Name}")); Indent(builder, depth) .Append("public static ").Append(factory.ServiceType).Append(' ').Append(factory.FactoryName) diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt200Awt201ModuleScanLimitations.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt200Awt201ModuleScanLimitations.cs new file mode 100644 index 0000000..5d19708 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt200Awt201ModuleScanLimitations.cs @@ -0,0 +1,108 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt200Awt201ModuleScanLimitations + { + [Fact] + public async Task Awt200_ReportsWhenAMatchHasAnInjectProperty() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IGrinder { } + public interface IPlugin { } + public interface IRoaster { } + + internal sealed class Roaster : IPlugin, IRoaster + { + [Inject] + public IGrinder? Grinder { get; set; } + } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT200*Roaster*Grinder*[Inject]*").AsWildcard() + .Because("an [Inject] property's semantics cannot be mirrored by the generated factory, so silently skipping it would construct the match differently than a container scan"); + } + + [Fact] + public async Task Awt200_ReportsWhenAMatchConstructorParameterIsMarkedArg() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + + internal sealed class Roaster : IPlugin, IRoaster + { + public Roaster([Arg] int strength) { } + } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT200*Roaster*strength*[Arg]*").AsWildcard() + .Because("an [Arg] parameter takes its value from the resolution call, which a mirrored factory parameter resolved from the graph would silently change"); + } + + [Fact] + public async Task Awt201_ReportsForAGenericModuleWithAScan() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT201*PluginModule*").AsWildcard() + .Because("no closed module exists for a consumer to import, and a bare-named partial would emit an unrelated non-generic class"); + await That(result.Sources.Keys.Where(key => key.Contains("ModuleScan"))).IsEmpty() + .Because("the orphan partial must not be emitted"); + } + + [Fact] + public async Task Awt201_ReportsForAModuleNestedInAGenericTypeWithAScan() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + public static partial class Outer + { + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + } + """); + + await That(result.Diagnostics).Contains("*AWT201*PluginModule*").AsWildcard() + .Because("a module nested in a generic type is just as unimportable as a generic module itself"); + } + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs b/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs index fded9d4..b89fbb8 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs @@ -44,8 +44,8 @@ await That(module).Contains("static partial class PluginModule") .Because("the module is re-opened as partial to receive the generated factory"); await That(module).Contains("global::Awaiten.GeneratedScanRegistrationAttribute(\"Awaiten__Scan_0_Roaster\", Lifetime = global::Awaiten.AwaitenLifetime.Singleton)") .Because("the match is registered under its accessible MatchingInterface, as a singleton scan match"); - await That(module).Contains("public static global::Lib.IRoaster Awaiten__Scan_0_Roaster(global::Lib.IClock clock) => new global::Lib.Roaster(clock);") - .Because("the factory returns the interface but constructs the internal implementation in the library"); + await That(module).Contains("public static global::Lib.IRoaster Awaiten__Scan_0_Roaster(global::Lib.IClock @clock) => new global::Lib.Roaster(@clock);") + .Because("the factory returns the interface but constructs the internal implementation in the library, with verbatim-prefixed parameter names so keyword-named parameters stay legal"); } [Fact] @@ -305,4 +305,116 @@ await That(result.Diagnostics).IsEmpty() await That(source).Contains("new global::Lib.Roaster(") .Because("with no assembly boundary the container constructs the internal match directly, as if the [Scan] were its own"); } + + [Fact] + public async Task SameCompilationModuleScanReportsScanDiagnosticsOnce() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + + [Module] + [Scan] + public static partial class PluginModule { } + + [Container] + [Import(typeof(PluginModule))] + public static partial class MyContainer { } + """); + + await That(result.Diagnostics.Count(diagnostic => diagnostic.Contains("AWT138"))).IsEqualTo(1) + .Because("the module pipeline and the container both see the same [Scan], but only the module pipeline reports on it"); + } + + [Fact] + public async Task KeywordNamedConstructorParameterCompilesAndResolves() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(""" + using Awaiten; + + namespace Lib; + + public interface IClock { } + public sealed class SystemClock : IClock { } + public interface IPlugin { } + public interface IRoaster { } + + internal sealed class Roaster : IPlugin, IRoaster + { + public Roaster(IClock @event) { } + } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Import(typeof(PluginModule))] + [Singleton] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("a keyword-named constructor parameter must be emitted verbatim-prefixed, not break the module's build"); + } + + [Fact] + public async Task OverlappingScansOnOneModuleEmitASingleFactoryPerMatch() + { + (_, Microsoft.CodeAnalysis.GeneratorDriverRunResult run) = Generator.RunGenerator(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + [Module] + [Scan(As = ScanAs.MatchingInterface)] + [Scan(As = ScanAs.MatchingInterface)] + public static partial class PluginModule { } + """, [], []); + string module = run.Results + .SelectMany(r => r.GeneratedSources) + .Single(s => s.HintName.Contains("ModuleScan")) + .SourceText.ToString(); + + await That(module).Contains("Awaiten__Scan_0_Roaster") + .Because("the first scan's match is emitted"); + await That(module).DoesNotContain("Awaiten__Scan_1_Roaster") + .Because("the second scan matched the same type under the same exposure, which a container would dedup to one collection member, so only one factory is emitted"); + } + + [Fact] + public async Task OverlappingScansWithDifferentLifetimesReportAwt142() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + [Module] + [Scan(As = ScanAs.MatchingInterface, Lifetime = AwaitenLifetime.Singleton)] + [Scan(As = ScanAs.MatchingInterface, Lifetime = AwaitenLifetime.Transient)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT142*Roaster*Singleton*Transient*").AsWildcard() + .Because("two scans of one module registering the same implementation with different lifetimes mirror the container's scan-lifetime conflict"); + } } From 701d1ec4ce53793e75490ab713cebe329266a5bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sun, 19 Jul 2026 14:43:37 +0200 Subject: [PATCH 5/5] fix: close seven review findings on self-compiled module scans (reinstates 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__ 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. --- Docs/pages/09-diagnostics.md | 39 ++++++- Docs/pages/registration/08-modules.md | 6 +- .../AnalyzerReleases.Unshipped.md | 4 +- .../AwaitenGenerator.Analysis.cs | 7 ++ .../AwaitenGenerator.Collection.cs | 46 ++++++-- .../AwaitenGenerator.ModuleScan.cs | 84 +++++++++----- .../AwaitenGenerator.Scan.cs | 77 +++++++++++-- .../AwaitenGenerator.cs | 21 +++- .../Awaiten.SourceGenerators/Diagnostics.cs | 46 ++++++-- .../Entities/ModuleScanModel.cs | 9 +- .../Sources/Sources.Module.cs | 10 +- .../GeneratedScanExpansionAttribute.cs | 16 +++ .../GeneratedScanRegistrationAttribute.cs | 6 + .../Expected/Awaiten_net10.0.txt | 6 + .../Expected/Awaiten_net8.0.txt | 6 + .../Expected/Awaiten_netstandard2.0.txt | 6 + ...nosticTests.Awt154ModuleScanNotExpanded.cs | 107 ++++++++++++++++++ ...gnosticTests.Awt194ModuleScanNotPartial.cs | 22 ++++ ...ts.Awt195Awt196Awt197ModuleScanExposure.cs | 26 ++++- ...ticTests.Awt202ModuleScanInAssembliesOf.cs | 31 +++++ .../SelfCompiledModuleScanTests.cs | 83 ++++++++++++-- 21 files changed, 571 insertions(+), 87 deletions(-) create mode 100644 Source/Awaiten/GeneratedScanExpansionAttribute.cs create mode 100644 Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt154ModuleScanNotExpanded.cs create mode 100644 Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt202ModuleScanInAssembliesOf.cs diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index 62f4e71..4bdc760 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -1332,7 +1332,22 @@ public static class EquipmentModule ### AWT154 -*Retired.* A `[Scan]` on a `[Module]` is now supported: the module compiles its own scan in its own build (see [self-compiled module scans](./registration/modules#self-compiled-scans)), so it is no longer rejected on import. The self-compilation constraints are reported as [AWT194](#awt194)–[AWT197](#awt197). +:::danger[Error] +An imported module declares a `[Scan]`, but its assembly carries no generated expansion. +::: + +```csharp +// In a referenced library that was compiled WITHOUT the Awaiten source generator: +[Module] +[Scan] // never expanded, so it would contribute nothing +public static partial class MenuModule; + +[Container] +[Import(typeof(MenuModule))] +public static partial class CoffeeShop; +``` + +*(Repurposed: this ID previously rejected any `[Scan]` on a module.)* A `[Scan]` on a `[Module]` is [self-compiled in the module's own build](./registration/modules#self-compiled-scans), which stamps the module with a generated marker even when the scan matched nothing. A module whose metadata carries a `[Scan]` but no marker was compiled without the Awaiten generator, or with a version predating self-compiled scans, so its scan would silently contribute nothing. Rebuild the library with the Awaiten generator referenced. Reported at the consumer's `[Import]`. The self-compilation constraints themselves are reported as [AWT194](#awt194)–[AWT197](#awt197), [AWT200](#awt200)–[AWT202](#awt202). ### AWT155 @@ -1388,8 +1403,8 @@ The generated factory is a `public` method whose parameters are resolved from th ### AWT196 -:::danger[Error] -A self-compiled module `[Scan]` match has no exposure interface accessible outside the module's assembly. +:::warning[Warning] +A self-compiled module `[Scan]` match has no exposure interface accessible outside the module's assembly, so it is skipped. ::: ```csharp @@ -1400,7 +1415,7 @@ internal sealed class Roaster : IPlugin; public static partial class PluginModule; ``` -A consumer resolves a self-compiled match only through an accessible interface. `ScanAs.Self` over an `internal` implementation exposes nothing nameable; expose it through a public interface (`ScanAs.MatchingInterface` or `ScanAs.Marker`), or exclude the match. +A consumer resolves a self-compiled match only through an accessible interface. `ScanAs.Self` over an `internal` implementation exposes nothing nameable, so the match registers nothing and is skipped, mirroring the warning severity a container scan gives a match it cannot register ([AWT182](#awt182)/[AWT188](#awt188)/[AWT193](#awt193)). Expose it through a public interface (`ScanAs.MatchingInterface` or `ScanAs.Marker`), or exclude the match. ### AWT197 @@ -1416,7 +1431,7 @@ internal sealed class Roaster : IPlugin, IRoaster; public static partial class PluginModule; ``` -A self-compiled match is reached through a generated factory that returns a single accessible interface, so a shared instance across several interfaces cannot be expressed (unlike a container `[Scan]`, whose matches coalesce on the concrete type). Narrow the exposure to a single interface (typically `ScanAs.MatchingInterface`), or exclude the match. This is a v1 limitation. +A self-compiled match is reached through a generated factory that returns a single accessible interface, so a shared instance across several interfaces cannot be expressed (unlike a container `[Scan]`, whose matches coalesce on the concrete type). The same applies across scans: two `[Scan]`s on one module that expose the same type under two different interfaces would emit two factories and silently split the instance, so that overlap is also rejected. Narrow the exposure to a single interface (typically `ScanAs.MatchingInterface`), or exclude the match. This is a v1 limitation. ### AWT200 @@ -1454,6 +1469,20 @@ public static partial class PluginModule; // no closed PluginModule exis A consumer imports a module by `typeof`, so there is no single closed module type to import from a generic declaration, and the generated partial could not re-open it by its bare name. Move the `[Scan]` onto a non-generic module. +### AWT202 + +:::danger[Error] +A module `[Scan]` declares `InAssembliesOf`, but a self-compiled scan sweeps only the module's own assembly. +::: + +```csharp +[Module] +[Scan(As = ScanAs.Marker, InAssembliesOf = new[] { typeof(OtherLibMarker) })] +public static partial class PluginModule; +``` + +A self-compiled scan exists to reach the module's own `internal` types. Sweeping another assembly from a module would see only that assembly's public types, which a container `[Scan]` with `InAssembliesOf` already covers, so the module form would silently do less than the container form. Remove `InAssembliesOf` to scan the module's own assembly, or move the `[Scan]` onto the container. + ## Keyed collections ### AWT159 diff --git a/Docs/pages/registration/08-modules.md b/Docs/pages/registration/08-modules.md index 78fc71e..89ed15f 100644 --- a/Docs/pages/registration/08-modules.md +++ b/Docs/pages/registration/08-modules.md @@ -71,9 +71,11 @@ public static partial class PluginModule; // partial, so the generator can add A consuming container `[Import]`s the module and resolves `IRoaster` without ever naming `Roaster`. Each match is registered like a container scan match: an explicit registration of the same service in the container (or another module) **overrides** it, and when several matches expose the **same** interface they **collect** — a `[Scan(As = ScanAs.Marker)]` over two internal plug-ins resolves as `IEnumerable`, exactly as it would on a container. Because diagnostics are reported in the *library's* build, the library author — not the consumer — sees any problem. -The module must be `partial` ([AWT194](../diagnostics#awt194)). A few v1 limitations apply, each reported at the library's source: a single match exposes through exactly one accessible interface ([AWT196](../diagnostics#awt196)/[AWT197](../diagnostics#awt197)) — several matches under one interface still collect, but one match cannot share a single instance across several interfaces the way a container scan can — and a match's constructor parameters must be types a consumer can name ([AWT195](../diagnostics#awt195)) without carrying `[Inject]`/`[Arg]` metadata the factory could not mirror ([AWT200](../diagnostics#awt200)). The module itself must be non-generic ([AWT201](../diagnostics#awt201)). +The module must be static ([AWT152](../diagnostics#awt152)), `partial` ([AWT194](../diagnostics#awt194)) and non-generic ([AWT201](../diagnostics#awt201)), and its scan sweeps the module's own assembly only ([AWT202](../diagnostics#awt202)). A few v1 limitations apply, each reported at the library's source. A match registers through exactly one accessible interface: a match with none is skipped with a warning ([AWT196](../diagnostics#awt196)), and one that would expose several is rejected ([AWT197](../diagnostics#awt197)), even when the exposures come from two different scans of the same module. Several matches under one interface still collect; what a self-compiled match cannot do is share a single instance across several interfaces the way a container scan can. A match's constructor parameters must be types a consumer can name ([AWT195](../diagnostics#awt195)), without `[Inject]`/`[Arg]` metadata the factory could not mirror ([AWT200](../diagnostics#awt200)). `SkipUnconstructable` works as on a container scan: it travels with each generated registration, so a consumer that cannot satisfy a match's dependencies drops the match with a warning ([AWT141](../diagnostics#awt141)) instead of failing the build. -When the module lives in the **same assembly as the container**, there is no assembly boundary to bridge: the container expands the module's `[Scan]` directly, exactly as if it were declared on the container itself, with full container-scan semantics (multi-interface exposure, `SkipUnconstructable`, direct construction of `internal` matches). The self-compiled factories are still generated into the module, so the same module keeps working for any *other* assembly that imports it; but note that the v1 limitations above are checked in the module's build regardless, since the module cannot know whether a cross-assembly consumer exists. +The expansion also stamps the module with a generated marker, present even when the scan matched nothing. A consuming container uses it as a version guard: importing a module whose metadata carries a `[Scan]` but no expansion, because the library was built without the Awaiten generator or with a version predating this feature, is an error ([AWT154](../diagnostics#awt154)) rather than a silent drop. + +When the module lives in the **same assembly as the container**, there is no assembly boundary to bridge: the container expands the module's `[Scan]` directly, exactly as if it were declared on the container itself, with full container-scan semantics (multi-interface exposure, direct construction of `internal` matches). The self-compiled factories are still generated into the module, so the same module keeps working for any *other* assembly that imports it; but note that the v1 limitations above are checked in the module's build regardless, since the module cannot know whether a cross-assembly consumer exists. ## One level deep diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index 4441512..25a0d42 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -55,6 +55,7 @@ AWT151 | Awaiten | Warning | An imported module declares no registrations AWT152 | Awaiten | Error | An imported [Module] class is not declared static AWT153 | Awaiten | Error | A module Factory/Instance member is not accessible from the generated container + AWT154 | Awaiten | Error | An imported module declares a [Scan] but its assembly carries no generated expansion, so it was built without the Awaiten generator (or a version predating self-compiled module scans) AWT155 | Awaiten | Warning | Two imported modules strongly register the same service with different implementations AWT156 | Awaiten | Warning | A generated Root/Scope is disposed synchronously although its container owns a service that implements IAsyncDisposable but not IDisposable AWT157 | Awaiten | Error | An [Inject(Optional = true)] property is required and cannot be omitted from the object initializer @@ -96,7 +97,8 @@ AWT193 | Awaiten | Warning | A [Scan] matched a type that is inaccessible to the generated container, so it is not registered AWT194 | Awaiten | Error | A [Module] that declares a [Scan] is not partial, so its scan cannot be self-compiled AWT195 | Awaiten | Error | A self-compiled module [Scan] match has a constructor parameter type inaccessible outside the module's assembly - AWT196 | Awaiten | Error | A self-compiled module [Scan] match has no exposure interface accessible outside the module's assembly + AWT196 | Awaiten | Warning | A self-compiled module [Scan] match has no exposure interface accessible outside the module's assembly, so it is skipped AWT197 | Awaiten | Error | A self-compiled module [Scan] match would be exposed under multiple interfaces, which a single-exposure factory cannot express AWT200 | Awaiten | Error | A self-compiled module [Scan] match carries [Inject]/[Arg] metadata the generated factory cannot mirror AWT201 | Awaiten | Error | A generic [Module] (or one nested in a generic type) declares a [Scan], so no closed module exists for a consumer to import + AWT202 | Awaiten | Error | A module [Scan] declares InAssembliesOf, but a self-compiled scan sweeps only the module's own assembly diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs index d08bb47..a36602e 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs @@ -1299,6 +1299,13 @@ internal static string Display(string fullyQualified) internal static string DisplayInstance(string implementationType) { int marker = implementationType.IndexOf("@" + DecoratorKeyPrefix, StringComparison.Ordinal); + if (marker < 0) + { + // A self-compiled module-scan match carries a synthetic '@scan:' identity (see + // CollectGeneratedScanRegistration); trim it so a diagnostic names the registered service. + marker = implementationType.IndexOf(ScanKeyMarker, StringComparison.Ordinal); + } + return Display(marker >= 0 ? implementationType.Substring(0, marker) : implementationType); } } diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs index 6bed87f..c2ab42a 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs @@ -7,6 +7,13 @@ namespace Awaiten.SourceGenerators; partial class AwaitenGenerator { + /// + /// The marker inside a self-compiled module-scan match's synthetic implementation identity + /// (<service>@scan:<qualified factory>, see ). + /// DisplayInstance keys off it to trim the synthetic suffix from diagnostics. + /// + private const string ScanKeyMarker = "@scan:"; + private static (List Raw, HashSet ConstraintRejectedServices) Collect( INamedTypeSymbol containerSymbol, List modules, @@ -224,12 +231,17 @@ private static void CollectGeneratedScanRegistration( } Lifetime lifetime = Lifetime.Transient; + bool skipUnconstructable = false; foreach (KeyValuePair argument in attribute.NamedArguments) { if (argument.Key == "Lifetime" && argument.Value.Value is int value) { lifetime = (Lifetime)value; } + else if (argument.Key == "SkipUnconstructable" && argument.Value.Value is bool skip) + { + skipUnconstructable = skip; + } } string serviceType = service.ToDisplayString(FullyQualified); @@ -238,7 +250,7 @@ private static void CollectGeneratedScanRegistration( result.Add(new RawRegistration( serviceType, - $"{serviceType}@scan:{qualifiedFactory}", + $"{serviceType}{ScanKeyMarker}{qualifiedFactory}", lifetime, service, location, @@ -246,6 +258,7 @@ private static void CollectGeneratedScanRegistration( factory, ServiceSymbol: service, IsScan: true, + ScanSkipsUnconstructable: skipUnconstructable, Origin: origin)); } @@ -369,16 +382,27 @@ private static bool ValidateImportedModule( } // A referenced-assembly module's [Scan] is self-compiled in the module's own build (the module emits a - // generated factory and lifetime registration per match, which this collection reads like any other module - // registration), so the consumer neither re-runs it nor rejects it. See ExpandModuleScan; AWT154 was - // retired. A same-compilation module's [Scan] is instead expanded by the container itself (see Collect). - - // AWT151: a module that declares no lifetime registrations imports nothing useful. A same-compilation - // module's [Scan] does contribute (the container expands it directly), so it counts here; a - // referenced-assembly module contributes through its self-compiled attributes instead, which - // DeclaresAnyRegistration already counts. - if (!DeclaresAnyRegistration(moduleAttributes) - && !(sameCompilation && HasAwaitenAttribute(moduleAttributes, "ScanAttribute"))) + // generated factory and lifetime registration per match, plus a [GeneratedScanExpansion] marker), which + // this collection reads like any other module registration. A same-compilation module's [Scan] is instead + // expanded by the container itself (see Collect); the generator cannot see the module's generated marker + // there (its own output), so the skew check below excludes it. + bool declaresScan = HasAwaitenAttribute(moduleAttributes, "ScanAttribute"); + + // AWT154: the metadata carries a [Scan] but no expansion marker, so the module assembly was built without + // the Awaiten generator (or a version predating self-compiled scans) and the scan would silently + // contribute nothing. The marker is emitted even for a scan that matched nothing, so its absence is + // conclusive, not a maybe. + if (!sameCompilation && declaresScan && !HasAwaitenAttribute(moduleAttributes, "GeneratedScanExpansionAttribute")) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ModuleScanNotExpanded, location, new EquatableArray([moduleName,]))); + } + + // AWT151: a module that declares no lifetime registrations imports nothing useful. A module with a [Scan] + // is exempt regardless of what the scan produced: the module's own build reports an empty scan at its + // declaration (AWT138/AWT184), and an unexpanded one is AWT154 above, so repeating the blame at the + // consumer's [Import] would point at code the consumer does not own. + if (!DeclaresAnyRegistration(moduleAttributes) && !declaresScan) { diagnostics.Add(new DiagnosticInfo( Diagnostics.EmptyModule, location, new EquatableArray([moduleName,]))); diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs index dc4c9ec..d1a5766 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.ModuleScan.cs @@ -17,9 +17,9 @@ partial class AwaitenGenerator /// module's own build, so diagnostics land at the library's source locations, and the scan sees the /// library's internal types (the whole point: keep implementations internal, expose interfaces). /// Reuses the container scan's candidate discovery, filters and their diagnostics - /// (AWT138/AWT140/AWT143/AWT172/AWT173/AWT174/AWT183/AWT184/AWT185), and adds AWT195/AWT196/AWT197/AWT200 + /// (AWT138/AWT143/AWT172/AWT173/AWT174/AWT183/AWT184/AWT185), and adds AWT195/AWT196/AWT197/AWT200/AWT202 /// for the self-compilation constraints (accessible parameters, a single accessible exposure per match, no - /// injection metadata the factory could not mirror). + /// injection metadata the factory could not mirror, the module's own assembly only). /// private static List CollectModuleScanFactories( INamedTypeSymbol module, @@ -52,7 +52,7 @@ private static List CollectModuleScanFactories( /// /// Expands one module [Scan] over its candidates, mirroring but emitting a /// generated factory per match instead of a . A module scans its own assembly - /// (or the assemblies named by InAssembliesOf), so an internal match is legitimate. + /// only (AWT202 rejects InAssembliesOf), so an internal match is legitimate. /// private static void ExpandModuleScan( AttributeData attribute, @@ -64,10 +64,15 @@ private static void ExpandModuleScan( { Location? location = attribute.ApplicationSyntaxReference?.GetSyntax().GetLocation(); - List? assemblies = ScanAssemblies(attribute); - if (assemblies is { Count: 0, }) + // v1: a module scans its own assembly only. An InAssembliesOf sweep from a module would see just the + // target's public types, which a container [Scan] already covers, so it is rejected (AWT202) rather + // than silently doing less than the container form. + if (ScanAssemblies(attribute) is not null) { - diagnostics.Add(new DiagnosticInfo(Diagnostics.ScanAssembliesEmpty, LocationInfo.From(location), new EquatableArray([]))); + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ModuleScanForeignAssemblies, + LocationInfo.From(location), + new EquatableArray([Display(module.ToDisplayString(FullyQualified)),]))); return; } @@ -80,7 +85,7 @@ private static void ExpandModuleScan( return; } - if (marker is null && MarkerlessScanError(match.Exposure, filters, assemblies) is { } reason) + if (marker is null && MarkerlessScanError(match.Exposure, filters, assemblies: null) is { } reason) { diagnostics.Add(new DiagnosticInfo(Diagnostics.MarkerlessScanInvalid, LocationInfo.From(location), new EquatableArray([reason,]))); return; @@ -99,7 +104,7 @@ private static void ExpandModuleScan( int assignable = 0; int registered = 0; int produced = 0; - foreach (ScanCandidate candidate in ScanCandidates(assemblies, compilation, marker, location, diagnostics)) + foreach (ScanCandidate candidate in ScanCandidates(assemblies: null, compilation, marker, location, diagnostics)) { assignable++; if (!PassesScanFilters(candidate.Type, filters, hits)) @@ -120,7 +125,7 @@ private static void ExpandModuleScan( produced += RegisterModuleScanMatch(candidate.Type, match, marker, openMarker, markerDefinition, module, compilation, factories, diagnostics); } - ReportScanFilterDiagnostics(filters, hits, new ScanFilterCounts(assignable, registered, produced), assemblies, markerDisplay, location, diagnostics); + ReportScanFilterDiagnostics(filters, hits, new ScanFilterCounts(assignable, registered, produced), assemblies: null, markerDisplay, location, diagnostics); } /// @@ -129,9 +134,11 @@ private static void ExpandModuleScan( /// factory returns one accessible type and a shared instance across interfaces cannot be expressed), and the /// constructor's parameters must all be accessible outside the assembly (AWT195), because they appear on the /// generated public factory and are resolved from the consumer's graph. A match carrying injection - /// metadata the factory cannot mirror ([Inject] properties, [Inject]/[Arg] parameters) is AWT200, and a - /// match another scan of this module already exposed the same way is deduped silently (a differing lifetime - /// across the overlap is AWT142, first scan wins). Returns the number of factories contributed (0 or 1). + /// metadata the factory cannot mirror ([Inject] properties, [Inject]/[Arg] parameters) is AWT200. A match + /// another scan of this module already exposed the same way is deduped silently (a differing lifetime + /// across the overlap is AWT142, first scan wins); one another scan exposed under a different + /// interface is AWT197, since two factories would split the shared instance. Returns the number of + /// factories contributed (0 or 1). /// private static int RegisterModuleScanMatch( INamedTypeSymbol type, @@ -201,14 +208,26 @@ private static int RegisterModuleScanMatch( INamedTypeSymbol service = distinct[0]; string serviceName = service.ToDisplayString(FullyQualified); - // Two [Scan]s on one module can match the same type under the same exposure. A container dedups such - // overlaps to one collection member (per-implementation identity); factories are per-match identities on - // the consumer, so dedup here instead, keeping the first. A differing lifetime across the overlapping - // scans is AWT142, mirroring the container, and the first scan's lifetime wins consistently. + // Two [Scan]s on one module can match the same type. Under the same exposure the overlap is deduped to + // the first factory, mirroring the container's per-implementation dedup, with AWT142 when the lifetimes + // differ (first scan's lifetime wins consistently). Under different exposures it is AWT197: each factory + // constructs its own instance, so the single shared instance a container scan gives one implementation + // across several interfaces cannot be expressed, and emitting both would silently split it. ModuleFactory? overlapping = factories.Find(factory => factory.ImplementationType == typeName); - Lifetime lifetime = overlapping?.Lifetime ?? match.Lifetime; if (overlapping is not null) { + if (overlapping.ServiceType != serviceName) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.ModuleScanMultipleExposures, + LocationInfo.From(match.Location), + new EquatableArray([ + Display(typeName), + $"{Display(overlapping.ServiceType)}, {Display(serviceName)}", + ]))); + return 0; + } + if (overlapping.Lifetime != match.Lifetime) { diagnostics.Add(new DiagnosticInfo( @@ -221,10 +240,7 @@ private static int RegisterModuleScanMatch( ]))); } - if (factories.Exists(factory => factory.ImplementationType == typeName && factory.ServiceType == serviceName)) - { - return 0; - } + return 0; } // [Inject] properties and [Inject]/[Arg] constructor parameters carry per-dependency semantics (keys, @@ -283,21 +299,33 @@ private static int RegisterModuleScanMatch( } factories.Add(new ModuleFactory( - ModuleFactoryName(type, factories.Count), + ModuleFactoryName(type), serviceName, typeName, - lifetime, + match.Lifetime, + match.SkipUnconstructable, new EquatableArray(parameters.ToArray()))); return 1; } /// - /// A deterministic, unique factory method name for one match: prefixed to avoid clashing with the module's - /// own members, suffixed with the running index so two matches of the same simple name never collide (which - /// would surface as an ambiguous factory on the consumer). + /// A deterministic factory method name for one match, stable across library versions: prefixed to avoid + /// clashing with the module's own members, carrying the match's simple name for readability, and suffixed + /// with an FNV-1a hash of its fully-qualified name so two same-named matches in different namespaces stay + /// distinct. The name depends only on the match's own full name — never on scan or discovery order — so + /// adding or removing other matches in a later library version does not rename it, and a consumer compiled + /// against the older assembly still binds after a drop-in upgrade. /// - private static string ModuleFactoryName(INamedTypeSymbol type, int index) - => $"Awaiten__Scan_{index}_{type.Name}"; + private static string ModuleFactoryName(INamedTypeSymbol type) + { + uint hash = 2166136261; + foreach (char character in type.ToDisplayString(FullyQualified)) + { + hash = unchecked((hash ^ character) * 16777619); + } + + return $"Awaiten__Scan_{type.Name}_{hash:x8}"; + } /// /// Whether a type can be named from an unrelated assembly: it (and every enclosing type) is public, diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs index cdedc95..69474f2 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Scan.cs @@ -1136,9 +1136,18 @@ private static bool PruneUnconstructableScanRound( HashSet checkedImpls = new(StringComparer.Ordinal); foreach (RawRegistration registration in raw.Where(r => r.ScanSkipsUnconstructable).ToList()) { - if (pinnedImpls.Contains(registration.ImplementationType) - || !checkedImpls.Add(registration.ImplementationType) - || FirstUnconstructableReason(registration.Implementation, containerSymbol, compilation, services, constraintRejected, external, variance) is not { } reason) + if (pinnedImpls.Contains(registration.ImplementationType) || !checkedImpls.Add(registration.ImplementationType)) + { + continue; + } + + // A self-compiled module-scan match is produced by its generated module factory, not by a constructor + // this container can see (the implementation is internal to the module's assembly), so its + // satisfiability is the factory's parameters, which mirror that constructor's. + string? reason = registration is { Production: ProductionKind.Factory, Origin: not null, ProductionMember: not null, } + ? FirstUnsatisfiableFactoryReason(registration, services, constraintRejected, external, variance) + : FirstUnconstructableReason(registration.Implementation, containerSymbol, compilation, services, constraintRejected, external, variance); + if (reason is null) { continue; } @@ -1183,7 +1192,59 @@ private static bool PruneUnconstructableScanRound( return "it has no constructor accessible to the container"; } - foreach (IParameterSymbol parameter in constructor.Parameters) + if (FirstUnsatisfiableParameterReason(constructor, services, constraintRejected, external, variance) is { } parameterReason) + { + return parameterReason; + } + + foreach (IPropertySymbol property in InjectedProperties(implementation)) + { + if (UnsatisfiableInjectedMemberReason(property, containerSymbol, compilation, services, constraintRejected, external.ServiceTypes) is { } reason) + { + return reason; + } + } + + return null; + } + + /// + /// The reason a self-compiled module-scan match cannot be produced (an AWT141 fragment), or + /// when every parameter of its generated module factory is satisfiable. The factory's + /// parameters mirror the internal implementation's constructor, plain and unattributed (the module's own + /// build rejected the rest, AWT200), so the check is the same per-parameter satisfiability a scanned + /// constructor gets. A factory member missing from the module is not a prune concern; the emitted call would + /// fail compilation with a targeted error anyway. + /// + private static string? FirstUnsatisfiableFactoryReason( + RawRegistration registration, + HashSet services, + HashSet constraintRejected, + ExternalSurface external, + VarianceState variance) + { + IMethodSymbol? factory = registration.Origin!.GetMembers(registration.ProductionMember!) + .OfType() + .FirstOrDefault(); + return factory is null + ? null + : FirstUnsatisfiableParameterReason(factory, services, constraintRejected, external, variance); + } + + /// + /// The reason one of a production method's parameters cannot be satisfied from the round's surface (an + /// AWT141 fragment), or when all are. Shared by the constructor check + /// () and the generated-module-factory check + /// (), whose parameters resolve identically. + /// + private static string? FirstUnsatisfiableParameterReason( + IMethodSymbol method, + HashSet services, + HashSet constraintRejected, + ExternalSurface external, + VarianceState variance) + { + foreach (IParameterSymbol parameter in method.Parameters) { ParameterModel model = ClassifyParameter(parameter, asyncFactory: false, external.ServiceTypes); bool satisfiable = @@ -1199,14 +1260,6 @@ model.Kind is DependencyKind.Arg or DependencyKind.External } } - foreach (IPropertySymbol property in InjectedProperties(implementation)) - { - if (UnsatisfiableInjectedMemberReason(property, containerSymbol, compilation, services, constraintRejected, external.ServiceTypes) is { } reason) - { - return reason; - } - } - return null; } diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs index 0a74fcf..637b826 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs @@ -65,9 +65,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) spc.ReportDiagnostic(diagnostic.ToDiagnostic()); } - // No factories means nothing to add (an errored or empty scan reports its diagnostics above); emitting - // an empty partial would be noise. - if (model.Factories.Count > 0) + // An expanded module always emits, even with zero factories: the partial then carries just the + // [GeneratedScanExpansion] marker, which a consuming container needs to tell "the scan matched + // nothing" from "the scan was never expanded" (AWT154). A module rejected before expansion + // (AWT152/AWT194/AWT201) emits nothing; its error already fails the build. + if (model.Expanded) { spc.AddSource(model.HintName, SourceText.From(Sources.EmitModule(model), Encoding.UTF8)); } @@ -104,6 +106,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) && declaration.Modifiers.Any(SyntaxKind.PartialKeyword); List factories = new(); + bool expanded = false; if (HasOpenTypeParameters(moduleSymbol)) { // AWT201: a generic module (or one nested in a generic type) has no single closed type a consumer could @@ -113,6 +116,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) LocationInfo.From(moduleSymbol.Locations.FirstOrDefault()), new EquatableArray([Display(moduleSymbol.ToDisplayString(FullyQualified)),]))); } + else if (!moduleSymbol.IsStatic) + { + // AWT152, reported here in the module's own build (the import-side check only reaches a module some + // container in the same solution imports): the generated partial re-opens the module as static, so + // emitting into a non-static class would surface as a raw partial-modifier compiler error instead. + diagnostics.Add(new DiagnosticInfo( + Diagnostics.NonStaticModule, + LocationInfo.From(moduleSymbol.Locations.FirstOrDefault()), + new EquatableArray([Display(moduleSymbol.ToDisplayString(FullyQualified)),]))); + } else if (!isPartial) { diagnostics.Add(new DiagnosticInfo( @@ -123,6 +136,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) else { factories = CollectModuleScanFactories(moduleSymbol, compilation, diagnostics); + expanded = true; } string? moduleNamespace = moduleSymbol.ContainingNamespace is { IsGlobalNamespace: false, } ns @@ -147,6 +161,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) new EquatableArray(containingTypes.ToArray()), moduleSymbol.Name, hintName, + expanded, new EquatableArray(factories.ToArray()), new EquatableArray(diagnostics.ToArray())); } diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index 2b69dd2..1e19c70 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -787,9 +787,21 @@ internal static class Diagnostics DiagnosticSeverity.Error, isEnabledByDefault: true); - // AWT154 (ScanOnModule) was retired: a [Scan] on a [Module] is now supported and self-compiled in the - // module's own build (the module emits a generated factory + registration per match), so a module-declared - // scan no longer errors on import. See ExpandModuleScan and the AWT194-AWT197 diagnostics. + /// + /// (Repurposed.) An imported module's metadata declares a [Scan] but carries no generated + /// expansion marker ([GeneratedScanExpansion]): the module assembly was compiled without the Awaiten + /// source generator, or with a version predating self-compiled module scans (whose AWT154 rejected a module + /// [Scan] outright). The scan would silently contribute nothing, so this stays an error, reported at + /// the consumer's [Import]. Not applicable to a same-compilation module, whose scan the container + /// expands directly and whose generated marker the generator cannot see (its own output). + /// + public static readonly DiagnosticDescriptor ModuleScanNotExpanded = new( + "AWT154", + "Module scan was not expanded", + "The imported module '{0}' declares a [Scan] but its assembly carries no generated expansion; it was compiled without the Awaiten source generator (or with a version predating self-compiled module scans), so the scan would contribute nothing; rebuild the module's assembly with the Awaiten generator referenced", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); /// /// Two different imported modules register the same service key with different implementations at @@ -1415,18 +1427,19 @@ internal static class Diagnostics /// /// A self-compiled module [Scan] matched a type but no exposure selected an interface accessible - /// outside the module's assembly, so a consumer could never name what the match registers under. A - /// ScanAs.Self exposure of an internal implementation hits this (the implementation type is - /// itself inaccessible), as does a match whose only convention/marker interface is internal. Expose - /// an accessible interface (ScanAs.MatchingInterface or ScanAs.Marker over a public - /// interface), or exclude the type from the scan. + /// outside the module's assembly, so a consumer could never name what the match registers under and the + /// match is skipped. A ScanAs.Self exposure of an internal implementation hits this (the + /// implementation type is itself inaccessible), as does a match whose only convention/marker interface is + /// internal. Expose an accessible interface (ScanAs.MatchingInterface or ScanAs.Marker + /// over a public interface), or exclude the type from the scan. A warning, not an error, mirroring the + /// container scan's skip-with-warning for a match it cannot register (AWT182/AWT188/AWT193). /// public static readonly DiagnosticDescriptor ModuleScanNoAccessibleExposure = new( "AWT196", "Module scan match has no accessible exposure", "'{0}' matched the module scan, but no exposure selected an interface accessible outside the module's assembly, so a consumer could not resolve it; expose it through a public interface (ScanAs.MatchingInterface or ScanAs.Marker) or exclude it from the scan", "Awaiten", - DiagnosticSeverity.Error, + DiagnosticSeverity.Warning, isEnabledByDefault: true); /// @@ -1472,4 +1485,19 @@ internal static class Diagnostics "Awaiten", DiagnosticSeverity.Error, isEnabledByDefault: true); + + /// + /// A module [Scan] declares InAssembliesOf. A self-compiled scan exists to reach the module's + /// own internal types; sweeping another assembly from a module sees only that assembly's public + /// types, which a container [Scan] already covers, so the module form would silently do less than + /// the container form. Rejected in v1: scan the module's own assembly, or move the [Scan] onto the + /// container. + /// + public static readonly DiagnosticDescriptor ModuleScanForeignAssemblies = new( + "AWT202", + "Module scan names other assemblies", + "the module '{0}' declares a [Scan] with InAssembliesOf, but a self-compiled scan sweeps only the module's own assembly (another assembly's internal types stay invisible to it, and its public types a container [Scan] already covers); remove InAssembliesOf or move the [Scan] onto the container", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } diff --git a/Source/Awaiten.SourceGenerators/Entities/ModuleScanModel.cs b/Source/Awaiten.SourceGenerators/Entities/ModuleScanModel.cs index d9e6f3c..0802eb3 100644 --- a/Source/Awaiten.SourceGenerators/Entities/ModuleScanModel.cs +++ b/Source/Awaiten.SourceGenerators/Entities/ModuleScanModel.cs @@ -17,20 +17,23 @@ internal sealed record ModuleScanModel( EquatableArray ContainingTypes, string TypeName, string HintName, + bool Expanded, EquatableArray Factories, EquatableArray Diagnostics); /// /// One generated module-scan factory: the emitted method name, the accessible exposure interface it returns -/// (also the service the registration exposes), the concrete implementation it constructs, the lifetime the -/// scan declared, and the constructor parameters mirrored onto the factory signature (resolved from the -/// consuming container's graph, exactly like a hand-written module factory's parameters). +/// (also the service the registration exposes), the concrete implementation it constructs, the lifetime and +/// SkipUnconstructable opt-in the scan declared, and the constructor parameters mirrored onto the +/// factory signature (resolved from the consuming container's graph, exactly like a hand-written module +/// factory's parameters). /// internal sealed record ModuleFactory( string FactoryName, string ServiceType, string ImplementationType, Lifetime Lifetime, + bool SkipUnconstructable, EquatableArray Parameters); /// A mirrored constructor parameter on a generated factory: its fully-qualified type and its name. diff --git a/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs b/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs index 1faf11a..9273174 100644 --- a/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs +++ b/Source/Awaiten.SourceGenerators/Sources/Sources.Module.cs @@ -35,6 +35,11 @@ public static string EmitModule(ModuleScanModel model) depth++; } + // The expansion marker: proof the module's [Scan] passed through the generator, emitted even when the + // scan matched nothing, so a consuming container can tell "matched nothing" from "never expanded" and + // report AWT154 only for the latter (a module assembly built without the Awaiten generator). + Indent(builder, depth).AppendLine("[global::Awaiten.GeneratedScanExpansionAttribute]"); + // One [GeneratedScanRegistration] per generated factory, applied to the re-opened partial module. The // consuming container reads these like a container [Scan]'s matches (collection-eligible and overridable). foreach (ModuleFactory factory in model.Factories.AsArray()) @@ -81,7 +86,10 @@ public static string EmitModule(ModuleScanModel model) /// registration - so a self-compiled scan behaves as close to a container scan as the assembly boundary allows. /// private static string RegistrationAttribute(ModuleFactory factory) - => $"global::Awaiten.GeneratedScanRegistrationAttribute<{factory.ServiceType}>(\"{factory.FactoryName}\", Lifetime = global::Awaiten.AwaitenLifetime.{factory.Lifetime})"; + { + string skip = factory.SkipUnconstructable ? ", SkipUnconstructable = true" : string.Empty; + return $"global::Awaiten.GeneratedScanRegistrationAttribute<{factory.ServiceType}>(\"{factory.FactoryName}\", Lifetime = global::Awaiten.AwaitenLifetime.{factory.Lifetime}{skip})"; + } private static void EmitModuleFactory(StringBuilder builder, int depth, ModuleFactory factory) { diff --git a/Source/Awaiten/GeneratedScanExpansionAttribute.cs b/Source/Awaiten/GeneratedScanExpansionAttribute.cs new file mode 100644 index 0000000..5da6cdd --- /dev/null +++ b/Source/Awaiten/GeneratedScanExpansionAttribute.cs @@ -0,0 +1,16 @@ +using System; +using System.ComponentModel; + +namespace Awaiten; + +/// +/// Emitted by the source generator onto every [Module] whose [Scan] it expanded, even when the +/// scan matched nothing. A consuming container uses it as the version-skew guard: a referenced module whose +/// metadata carries a [Scan] but not this marker was compiled without the Awaiten generator (or with a +/// version predating self-compiled module scans), so its scan would be silently dropped; the container reports +/// AWT154 instead. +/// +/// Generated code, not intended to be written by hand. +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class GeneratedScanExpansionAttribute : Attribute; diff --git a/Source/Awaiten/GeneratedScanRegistrationAttribute.cs b/Source/Awaiten/GeneratedScanRegistrationAttribute.cs index f572eb0..a431804 100644 --- a/Source/Awaiten/GeneratedScanRegistrationAttribute.cs +++ b/Source/Awaiten/GeneratedScanRegistrationAttribute.cs @@ -38,6 +38,12 @@ public GeneratedScanRegistrationAttribute(string factory) /// The lifetime the scan declared. Defaults to , matching [Scan]. public AwaitenLifetime Lifetime { get; set; } = AwaitenLifetime.Transient; + + /// + /// Whether the scan declared SkipUnconstructable: the consuming container drops the match with a + /// warning (AWT141) instead of an error when a factory parameter is not satisfiable from its graph. + /// + public bool SkipUnconstructable { get; set; } } #pragma warning restore S2326 diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt index 61aa9b3..2bd7745 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt @@ -110,6 +110,11 @@ namespace Awaiten public FromKeyAttribute(string key) { } public object Key { get; } } + [System.AttributeUsage(System.AttributeTargets.Class, Inherited=false)] + public sealed class GeneratedScanExpansionAttribute : System.Attribute + { + public GeneratedScanExpansionAttribute() { } + } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] public sealed class GeneratedScanRegistrationAttribute : System.Attribute where TService : class @@ -117,6 +122,7 @@ namespace Awaiten public GeneratedScanRegistrationAttribute(string factory) { } public string Factory { get; } public Awaiten.AwaitenLifetime Lifetime { get; set; } + public bool SkipUnconstructable { get; set; } } public interface IAsyncInitializable { diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt index 1cd152d..b4b7f75 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt @@ -110,6 +110,11 @@ namespace Awaiten public FromKeyAttribute(string key) { } public object Key { get; } } + [System.AttributeUsage(System.AttributeTargets.Class, Inherited=false)] + public sealed class GeneratedScanExpansionAttribute : System.Attribute + { + public GeneratedScanExpansionAttribute() { } + } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] public sealed class GeneratedScanRegistrationAttribute : System.Attribute where TService : class @@ -117,6 +122,7 @@ namespace Awaiten public GeneratedScanRegistrationAttribute(string factory) { } public string Factory { get; } public Awaiten.AwaitenLifetime Lifetime { get; set; } + public bool SkipUnconstructable { get; set; } } public interface IAsyncInitializable { diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt index d9dc704..294e31f 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt @@ -110,6 +110,11 @@ namespace Awaiten public FromKeyAttribute(string key) { } public object Key { get; } } + [System.AttributeUsage(System.AttributeTargets.Class, Inherited=false)] + public sealed class GeneratedScanExpansionAttribute : System.Attribute + { + public GeneratedScanExpansionAttribute() { } + } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] public sealed class GeneratedScanRegistrationAttribute : System.Attribute where TService : class @@ -117,6 +122,7 @@ namespace Awaiten public GeneratedScanRegistrationAttribute(string factory) { } public string Factory { get; } public Awaiten.AwaitenLifetime Lifetime { get; set; } + public bool SkipUnconstructable { get; set; } } public interface IAsyncInitializable { diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt154ModuleScanNotExpanded.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt154ModuleScanNotExpanded.cs new file mode 100644 index 0000000..f66e145 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt154ModuleScanNotExpanded.cs @@ -0,0 +1,107 @@ +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt154ModuleScanNotExpanded + { + [Fact] + public async Task Awt154_ReportsWhenTheModuleAssemblyWasBuiltWithoutTheGenerator() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + using Awaiten; + + namespace Lib; + + public interface IClock { } + public sealed class SystemClock : IClock { } + public interface IPlugin { } + + internal sealed class Roaster : IPlugin { } + + [Module] + [Singleton] + [Scan(As = ScanAs.Marker)] + public static partial class PluginModule { } + """, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Import(typeof(PluginModule))] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).Contains("*AWT154*PluginModule*").AsWildcard() + .Because("the module's metadata carries a [Scan] but no generated expansion marker, so the scan would silently contribute nothing; the module declaring another registration must not mask that"); + } + + [Fact] + public async Task Awt154_ReportsForAScanOnlyModuleWithoutMisleadingAwt151() + { + GeneratorResult result = Generator.RunWithReferencedAssembly(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + internal sealed class Roaster : IPlugin { } + + [Module] + [Scan(As = ScanAs.Marker)] + public static partial class PluginModule { } + """, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Import(typeof(PluginModule))] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).Contains("*AWT154*PluginModule*").AsWildcard() + .Because("the unexpanded scan is the actual fault"); + await That(result.Diagnostics).DoesNotContain("*AWT151*").AsWildcard() + .Because("a module declaring a [Scan] is not an empty module; AWT154 names the real problem instead"); + } + + [Fact] + public async Task NoAwt154WhenTheGeneratorExpandedAScanThatMatchedNothing() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + + [Module] + [Scan(As = ScanAs.Marker)] + public static partial class PluginModule { } + """, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Import(typeof(PluginModule))] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).DoesNotContain("*AWT154*").AsWildcard() + .Because("the generated expansion marker is emitted even for a scan that matched nothing, so an empty expansion is distinguishable from a missing one"); + await That(result.Diagnostics).DoesNotContain("*AWT151*").AsWildcard() + .Because("the empty scan was already reported in the module's own build, not blamed on the consumer's [Import]"); + } + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt194ModuleScanNotPartial.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt194ModuleScanNotPartial.cs index e37a1d1..4a07179 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt194ModuleScanNotPartial.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt194ModuleScanNotPartial.cs @@ -6,6 +6,28 @@ public partial class DiagnosticTests { public class Awt194ModuleScanNotPartial { + [Fact] + public async Task Awt152_ReportsForANonStaticModuleWithAScanInTheModulesOwnBuild() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + internal sealed class Roaster : IPlugin { } + + [Module] + [Scan(As = ScanAs.Marker)] + public partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT152*PluginModule*").AsWildcard() + .Because("a non-static module is reported in its own build rather than surfacing as a raw partial-modifier compiler error inside the generated static partial"); + await That(result.Sources.Keys.Where(key => key.Contains("ModuleScan"))).IsEmpty() + .Because("emitting a static partial for a non-static class would not compile"); + } + [Fact] public async Task ReportsWhenAModuleWithAScanIsNotPartial() { diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt195Awt196Awt197ModuleScanExposure.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt195Awt196Awt197ModuleScanExposure.cs index 6b31def..e0fdbe5 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt195Awt196Awt197ModuleScanExposure.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt195Awt196Awt197ModuleScanExposure.cs @@ -48,8 +48,8 @@ internal sealed class Roaster : IPlugin { } public static partial class PluginModule { } """); - await That(result.Diagnostics).Contains("*AWT196*Roaster*").AsWildcard() - .Because("a Self exposure of an internal implementation gives a consumer no accessible type to resolve"); + await That(result.Diagnostics).Contains("*warning AWT196*Roaster*").AsWildcard() + .Because("a Self exposure of an internal implementation gives a consumer no accessible type to resolve; the match is skipped with a warning, mirroring the container scan's AWT182/AWT188/AWT193 severity"); } [Fact] @@ -73,6 +73,28 @@ await That(result.Diagnostics).Contains("*AWT197*Roaster*").AsWildcard() .Because("a self-compiled match is reached through a single-interface factory, so multiple exposures are unsupported in v1"); } + [Fact] + public async Task Awt197_ReportsWhenTwoScansExposeOneMatchUnderDifferentInterfaces() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + public interface IRoaster { } + internal sealed class Roaster : IPlugin, IRoaster { } + + [Module] + [Scan(As = ScanAs.Marker)] + [Scan(As = ScanAs.Marker)] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT197*Roaster*IPlugin, Lib.IRoaster*").AsWildcard() + .Because("two scans exposing the same type under different interfaces would emit two factories, splitting the single shared instance a container scan gives one implementation, so the overlap is rejected like a multi-exposure within one scan"); + } + [Fact] public async Task DoesNotReportForASingleAccessibleExposure() { diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt202ModuleScanInAssembliesOf.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt202ModuleScanInAssembliesOf.cs new file mode 100644 index 0000000..ef36511 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt202ModuleScanInAssembliesOf.cs @@ -0,0 +1,31 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt202ModuleScanInAssembliesOf + { + [Fact] + public async Task Awt202_ReportsWhenAModuleScanNamesAssemblies() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace Lib; + + public interface IPlugin { } + internal sealed class Roaster : IPlugin { } + + [Module] + [Scan(As = ScanAs.Marker, InAssembliesOf = new[] { typeof(IPlugin) })] + public static partial class PluginModule { } + """); + + await That(result.Diagnostics).Contains("*AWT202*PluginModule*").AsWildcard() + .Because("a self-compiled scan sweeps only the module's own assembly; InAssembliesOf would see just another assembly's public types, which a container [Scan] already covers"); + await That(result.Sources.Keys.Where(key => key.Contains("ModuleScan"))).IsNotEmpty() + .Because("the module still emits its expansion marker; the rejected scan just contributes no factories"); + } + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs b/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs index b89fbb8..6fa56c2 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/SelfCompiledModuleScanTests.cs @@ -9,6 +9,22 @@ namespace Awaiten.SourceGenerators.Tests; /// public class SelfCompiledModuleScanTests { + /// + /// Mirrors the generator's factory naming (ModuleFactoryName): the match's simple name plus an + /// FNV-1a hash of its fully-qualified name, stable across library versions and scan order. + /// + private static string FactoryName(string fullyQualifiedName) + { + uint hash = 2166136261; + foreach (char character in fullyQualifiedName) + { + hash = unchecked((hash ^ character) * 16777619); + } + + string simpleName = fullyQualifiedName[(fullyQualifiedName.LastIndexOf('.') + 1)..]; + return $"Awaiten__Scan_{simpleName}_{hash:x8}"; + } + private const string LibrarySource = """ using Awaiten; @@ -42,9 +58,11 @@ public async Task ModuleSelfCompilesItsScanIntoAFactory() await That(module).Contains("static partial class PluginModule") .Because("the module is re-opened as partial to receive the generated factory"); - await That(module).Contains("global::Awaiten.GeneratedScanRegistrationAttribute(\"Awaiten__Scan_0_Roaster\", Lifetime = global::Awaiten.AwaitenLifetime.Singleton)") + await That(module).Contains("[global::Awaiten.GeneratedScanExpansionAttribute]") + .Because("the expansion marker lets a consuming container tell an expanded scan from one built without the generator (AWT154)"); + await That(module).Contains($"global::Awaiten.GeneratedScanRegistrationAttribute(\"{FactoryName("global::Lib.Roaster")}\", Lifetime = global::Awaiten.AwaitenLifetime.Singleton)") .Because("the match is registered under its accessible MatchingInterface, as a singleton scan match"); - await That(module).Contains("public static global::Lib.IRoaster Awaiten__Scan_0_Roaster(global::Lib.IClock @clock) => new global::Lib.Roaster(@clock);") + await That(module).Contains($"public static global::Lib.IRoaster {FactoryName("global::Lib.Roaster")}(global::Lib.IClock @clock) => new global::Lib.Roaster(@clock);") .Because("the factory returns the interface but constructs the internal implementation in the library, with verbatim-prefixed parameter names so keyword-named parameters stay legal"); } @@ -69,7 +87,7 @@ await That(result.Diagnostics).IsEmpty() .Because("the internal implementation resolves through its interface with no missing dependency"); string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; - await That(source).Contains("global::Lib.PluginModule.Awaiten__Scan_0_Roaster") + await That(source).Contains($"global::Lib.PluginModule.{FactoryName("global::Lib.Roaster")}") .Because("the container calls the module's generated factory to build the implementation"); await That(source).DoesNotContain("new global::Lib.Roaster") .Because("the consumer never constructs the internal implementation directly"); @@ -188,9 +206,9 @@ public static partial class MyContainer await That(result.Diagnostics).IsEmpty() .Because("two internal implementations of one marker interface collect instead of colliding (no AWT111)"); string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; - await That(source).Contains("global::Lib.PluginModule.Awaiten__Scan_0_Alpha") + await That(source).Contains($"global::Lib.PluginModule.{FactoryName("global::Lib.Alpha")}") .Because("the first match is a member of the IEnumerable collection"); - await That(source).Contains("global::Lib.PluginModule.Awaiten__Scan_1_Bravo") + await That(source).Contains($"global::Lib.PluginModule.{FactoryName("global::Lib.Bravo")}") .Because("the second match is a member of the IEnumerable collection too, exactly as a container scan would collect them"); } @@ -264,10 +282,10 @@ public static partial class MyContainer """); string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; - await That(source).Contains("global::Lib.ModuleA.Awaiten__Scan_0_Handler") + await That(source).Contains($"global::Lib.ModuleA.{FactoryName("global::Lib.A.Handler")}") .Because("module A's match is a member of the collection"); - await That(source).Contains("global::Lib.ModuleB.Awaiten__Scan_0_Handler") - .Because("module B's match must not be deduped away by sharing module A's per-module factory name"); + await That(source).Contains($"global::Lib.ModuleB.{FactoryName("global::Lib.B.Handler")}") + .Because("module B's match must not be deduped away by sharing module A's factory name, and the name hash keeps same-named matches distinct"); } [Fact] @@ -390,12 +408,57 @@ public static partial class PluginModule { } .Single(s => s.HintName.Contains("ModuleScan")) .SourceText.ToString(); - await That(module).Contains("Awaiten__Scan_0_Roaster") + await That(module).Contains(FactoryName("global::Lib.Roaster")) .Because("the first scan's match is emitted"); - await That(module).DoesNotContain("Awaiten__Scan_1_Roaster") + await That(module.Split("public static").Length - 1).IsEqualTo(1) .Because("the second scan matched the same type under the same exposure, which a container would dedup to one collection member, so only one factory is emitted"); } + [Fact] + public async Task SkipUnconstructableMatchIsPrunedAtTheConsumer() + { + GeneratorResult result = Generator.RunWithGeneratedReferencedAssembly(""" + using Awaiten; + + namespace Lib; + + public interface IExotic { } + public interface IPlugin { } + public interface IRoaster { } + public interface IGrinder { } + + internal sealed class Roaster : IPlugin, IRoaster + { + public Roaster(IExotic exotic) { } + } + + internal sealed class Grinder : IPlugin, IGrinder { } + + [Module] + [Scan(As = ScanAs.MatchingInterface, SkipUnconstructable = true)] + public static partial class PluginModule { } + """, """ + using Awaiten; + using Lib; + + namespace MyCode; + + [Container] + [Import(typeof(PluginModule))] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).Contains("*AWT141*IRoaster*IExotic*").AsWildcard() + .Because("the scan's SkipUnconstructable travels on the generated registration, so the consumer prunes the match whose factory parameter it cannot resolve, with the same AWT141 a container scan gives"); + await That(result.Diagnostics).DoesNotContain("*error*").AsWildcard() + .Because("pruning replaces the missing-dependency error"); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains($"global::Lib.PluginModule.{FactoryName("global::Lib.Grinder")}") + .Because("the constructable match survives the prune"); + } + [Fact] public async Task OverlappingScansWithDifferentLifetimesReportAwt142() {