From 4de8fb90fe0b3bb1a6c63e83f4ff2f3a7f34a355 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 28 Mar 2026 17:03:48 +0000 Subject: [PATCH 1/4] Add IModuleOptions pattern for configurable module behavior Modules can now expose configurable options via the IModuleOptions marker interface. The source generator auto-discovers these classes and generates typed Configure{Module}() extension methods on SimpleModuleOptions, enabling host apps to customize module behavior without modifying module code. - Add IModuleOptions marker interface to SimpleModule.Core - Generator discovers IModuleOptions in module and contracts assemblies - Generate ConfigureProducts() (etc.) extension methods per module - Register IOptions defaults and apply user overrides at startup - Add SM0044 (multiple options per module) and SM0045 (orphan options) diagnostics - Add ProductsModuleOptions as reference implementation - Wire IOptions into Products BrowseEndpoint --- CLAUDE.md | 2 +- docs/CONSTITUTION.md | 29 ++++++ framework/SimpleModule.Core/IModuleOptions.cs | 21 ++++ .../AnalyzerReleases.Unshipped.md | 2 + .../Discovery/DiscoveryData.cs | 15 +++ .../Discovery/SymbolDiscovery.cs | 73 ++++++++++++++ .../Emitters/DiagnosticEmitter.cs | 68 +++++++++++++ .../Emitters/HostingExtensionsEmitter.cs | 5 + .../Emitters/ModuleOptionsEmitter.cs | 95 +++++++++++++++++++ .../ModuleDiscovererGenerator.cs | 1 + .../SimpleModuleOptions.cs | 31 ++++++ .../ProductsModuleOptions.cs | 26 +++++ .../Views/BrowseEndpoint.cs | 10 +- .../TopologicalSortTests.cs | 3 + 14 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 framework/SimpleModule.Core/IModuleOptions.cs create mode 100644 framework/SimpleModule.Generator/Emitters/ModuleOptionsEmitter.cs create mode 100644 modules/Products/src/SimpleModule.Products/ProductsModuleOptions.cs diff --git a/CLAUDE.md b/CLAUDE.md index 8496a512..13b21006 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ See [docs/CONSTITUTION.md](docs/CONSTITUTION.md) for the authoritative reference - Module boundaries, dependencies, and data ownership - Communication patterns (contracts and events) - Endpoint, frontend, and authorization rules -- Compiler-enforced diagnostics (SM0001-SM0043) +- Compiler-enforced diagnostics (SM0001-SM0045) - Framework contributor guidelines ## Key Constraints diff --git a/docs/CONSTITUTION.md b/docs/CONSTITUTION.md index b87dcaad..ba409d6f 100644 --- a/docs/CONSTITUTION.md +++ b/docs/CONSTITUTION.md @@ -51,6 +51,33 @@ All hooks are optional. All have default no-op implementations. 8. **OnStopAsync** -- graceful shutdown cleanup 9. **CheckHealthAsync** -- report module health status (Healthy, Degraded, Unhealthy) +### Module Options + +Modules can expose configurable behavior via the `IModuleOptions` marker interface. The source generator auto-discovers these classes and generates typed `Configure{Module}()` extension methods on `SimpleModuleOptions`. + +```csharp +// Module defines options +public class ProductsModuleOptions : IModuleOptions +{ + public int DefaultPageSize { get; set; } = 10; + public int MaxPageSize { get; set; } = 100; +} + +// Host app configures them +builder.AddSimpleModule(o => +{ + o.ConfigureProducts(p => p.MaxPageSize = 50); +}); + +// Module reads them via IOptions +public class BrowseEndpoint(IOptions options) : IViewEndpoint { ... } +``` + +**Rules:** +- At most one `IModuleOptions` class per module (SM0044 warns on duplicates). +- Options classes may live in the module assembly or its Contracts assembly. +- SM0045 warns if an options class cannot be matched to a known module. + ### What a Module Must Never Expose - Entity classes @@ -371,6 +398,8 @@ All SM diagnostics are emitted by the Roslyn source generator at compile time. ` | SM0002 | Warning | Module name must not be empty | | SM0040 | Error | No duplicate module names | | SM0043 | Warning | Module must override at least one IModule method | +| SM0044 | Warning | Multiple IModuleOptions for same module | +| SM0045 | Warning | IModuleOptions class not associated with any module | ### Dependencies diff --git a/framework/SimpleModule.Core/IModuleOptions.cs b/framework/SimpleModule.Core/IModuleOptions.cs new file mode 100644 index 00000000..55574df1 --- /dev/null +++ b/framework/SimpleModule.Core/IModuleOptions.cs @@ -0,0 +1,21 @@ +namespace SimpleModule.Core; + +/// +/// Marker interface for module options classes. Implementations are auto-discovered +/// by the source generator, registered with IOptions<T>, and exposed as +/// typed Configure{Module}() methods on SimpleModuleOptions. +/// +/// +/// Each module may define at most one options class implementing this interface. +/// The host application can then configure module behavior at startup: +/// +/// builder.AddSimpleModule(o => +/// { +/// o.ConfigureProducts(p => p.MaxPageSize = 50); +/// }); +/// +/// Module code reads configured values via IOptions<TOptions> injection. +/// +#pragma warning disable CA1040 // Avoid empty interfaces — marker interface by design +public interface IModuleOptions; +#pragma warning restore CA1040 diff --git a/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md b/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md index c5223b99..8aecc408 100644 --- a/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md +++ b/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md @@ -30,3 +30,5 @@ SM0040 | SimpleModule.Generator | Error | Duplicate module name SM0041 | SimpleModule.Generator | Warning | View page name does not match module name prefix SM0042 | SimpleModule.Generator | Error | Module has view endpoints but no ViewPrefix SM0043 | SimpleModule.Generator | Warning | Module does not override any IModule methods +SM0044 | SimpleModule.Generator | Warning | Multiple IModuleOptions for same module +SM0045 | SimpleModule.Generator | Warning | IModuleOptions class not associated with any module diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs index b6fba426..edf0cec5 100644 --- a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs @@ -29,6 +29,7 @@ internal readonly record struct DiscoveryData( ImmutableArray PermissionClasses, ImmutableArray Interceptors, ImmutableArray VogenValueObjects, + ImmutableArray ModuleOptions, string HostAssemblyName ) { @@ -44,6 +45,7 @@ string HostAssemblyName ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, "" ); @@ -60,6 +62,7 @@ public bool Equals(DiscoveryData other) && PermissionClasses.SequenceEqual(other.PermissionClasses) && Interceptors.SequenceEqual(other.Interceptors) && VogenValueObjects.SequenceEqual(other.VogenValueObjects) + && ModuleOptions.SequenceEqual(other.ModuleOptions) && HostAssemblyName == other.HostAssemblyName; } @@ -77,6 +80,7 @@ public override int GetHashCode() hash = HashHelper.HashArray(hash, PermissionClasses); hash = HashHelper.HashArray(hash, Interceptors); hash = HashHelper.HashArray(hash, VogenValueObjects); + hash = HashHelper.HashArray(hash, ModuleOptions); hash = HashHelper.Combine(hash, (HostAssemblyName ?? "").GetHashCode()); return hash; } @@ -312,6 +316,11 @@ public override int GetHashCode() } } +internal readonly record struct ModuleOptionsRecord( + string FullyQualifiedName, + string ModuleName +); + internal readonly record struct VogenValueObjectRecord( string TypeFqn, string ConverterFqn, @@ -430,4 +439,10 @@ internal sealed class InterceptorInfo public List ConstructorParamTypeFqns { get; set; } = new(); } +internal sealed class ModuleOptionsInfo +{ + public string FullyQualifiedName { get; set; } = ""; + public string ModuleName { get; set; } = ""; +} + #endregion diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 8addf8db..f2b944e2 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -395,6 +395,42 @@ is not IAssemblySymbol assemblySymbol } ); + // Step 3f: Find IModuleOptions implementors in module and contracts assemblies + var moduleOptionsList = new List(); + var moduleOptionsSymbol = compilation.GetTypeByMetadataName( + "SimpleModule.Core.IModuleOptions" + ); + if (moduleOptionsSymbol is not null) + { + foreach (var module in modules) + { + if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol)) + continue; + + var moduleAssembly = typeSymbol.ContainingAssembly; + FindModuleOptionsClasses( + moduleAssembly.GlobalNamespace, + moduleOptionsSymbol, + module.ModuleName, + moduleOptionsList + ); + } + + // Also scan contracts assemblies for module options classes + foreach (var kvp in contractsAssemblySymbols) + { + if (contractsAssemblyMap.TryGetValue(kvp.Key, out var moduleName)) + { + FindModuleOptionsClasses( + kvp.Value.GlobalNamespace, + moduleOptionsSymbol, + moduleName, + moduleOptionsList + ); + } + } + } + // Step 4: Detect dependencies and illegal references var dependencies = new List(); var illegalReferences = new List(); @@ -537,6 +573,9 @@ is not IAssemblySymbol assemblySymbol )) .ToImmutableArray(), vogenValueObjects.ToImmutableArray(), + moduleOptionsList + .Select(o => new ModuleOptionsRecord(o.FullyQualifiedName, o.ModuleName)) + .ToImmutableArray(), hostAssemblyName ); } @@ -1227,6 +1266,40 @@ m is IFieldSymbol field } } + private static void FindModuleOptionsClasses( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol moduleOptionsSymbol, + string moduleName, + List results + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + if (member is INamespaceSymbol childNs) + { + FindModuleOptionsClasses(childNs, moduleOptionsSymbol, moduleName, results); + } + else if ( + member is INamedTypeSymbol typeSymbol + && typeSymbol.TypeKind == TypeKind.Class + && !typeSymbol.IsAbstract + && !typeSymbol.IsStatic + && ImplementsInterface(typeSymbol, moduleOptionsSymbol) + ) + { + results.Add( + new ModuleOptionsInfo + { + FullyQualifiedName = typeSymbol.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ), + ModuleName = moduleName, + } + ); + } + } + } + private static void FindInterceptorTypes( INamespaceSymbol namespaceSymbol, INamedTypeSymbol saveChangesInterceptorSymbol, diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index a81ba663..1c2e4927 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -260,6 +260,24 @@ internal sealed class DiagnosticEmitter : IEmitter isEnabledByDefault: true ); + private static readonly DiagnosticDescriptor MultipleModuleOptions = new( + id: "SM0044", + title: "Multiple IModuleOptions for same module", + messageFormat: "Module '{0}' has multiple IModuleOptions implementations: '{1}' and '{2}'. Each module should have at most one options class. Only the first will be used.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor ModuleOptionsOrphan = new( + id: "SM0045", + title: "IModuleOptions class not associated with any module", + messageFormat: "Options class '{0}' implements IModuleOptions but is associated with module '{1}' which was not found. Ensure the options class is in the same assembly as its module or the module's contracts assembly.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + public void Emit(SourceProductionContext context, DiscoveryData data) { // SM0002: Empty module name @@ -850,6 +868,56 @@ public void Emit(SourceProductionContext context, DiscoveryData data) } } } + + // SM0044: Multiple IModuleOptions for same module + var optionsByModule = new Dictionary>(); + foreach (var opt in data.ModuleOptions) + { + if (!optionsByModule.TryGetValue(opt.ModuleName, out var list)) + { + list = new List(); + optionsByModule[opt.ModuleName] = list; + } + list.Add(opt); + } + + foreach (var kvp in optionsByModule) + { + if (kvp.Value.Count > 1) + { + context.ReportDiagnostic( + Diagnostic.Create( + MultipleModuleOptions, + Location.None, + kvp.Key, + Strip(kvp.Value[0].FullyQualifiedName), + Strip(kvp.Value[1].FullyQualifiedName) + ) + ); + } + } + + // SM0045: IModuleOptions class not associated with a known module + var knownModuleNames = new HashSet(); + foreach (var module in data.Modules) + { + knownModuleNames.Add(module.ModuleName); + } + + foreach (var opt in data.ModuleOptions) + { + if (!knownModuleNames.Contains(opt.ModuleName)) + { + context.ReportDiagnostic( + Diagnostic.Create( + ModuleOptionsOrphan, + Location.None, + Strip(opt.FullyQualifiedName), + opt.ModuleName + ) + ); + } + } } private static string Strip(string fqn) => TypeMappingHelpers.StripGlobalPrefix(fqn); diff --git a/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs index 7788d2ee..7d5c1676 100644 --- a/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/HostingExtensionsEmitter.cs @@ -47,10 +47,12 @@ public void Emit(SourceProductionContext context, DiscoveryData data) sb.AppendLine(" this WebApplicationBuilder builder,"); sb.AppendLine(" System.Action? configure = null)"); sb.AppendLine(" {"); + sb.AppendLine(" SimpleModuleOptions? smOptions = null;"); sb.AppendLine(" builder.AddSimpleModuleInfrastructure(o =>"); sb.AppendLine(" {"); sb.AppendLine(" o.ShellComponent ??= typeof(InertiaShell);"); sb.AppendLine(" configure?.Invoke(o);"); + sb.AppendLine(" smOptions = o;"); sb.AppendLine(" });"); sb.AppendLine(); sb.AppendLine(" // Source-generated module registration"); @@ -60,6 +62,9 @@ public void Emit(SourceProductionContext context, DiscoveryData data) " builder.Services.AddModuleDbContext(builder.Configuration, global::SimpleModule.Database.DatabaseConstants.HostModuleName);" ); sb.AppendLine(); + sb.AppendLine(" // Register module options (IModuleOptions auto-discovery)"); + sb.AppendLine(" smOptions?.ApplyModuleOptions(builder.Services, ModuleOptionsExtensions.RegisterModuleOptionsDefaults);"); + sb.AppendLine(); sb.AppendLine(" builder.Services.CollectModuleMenuItems();"); sb.AppendLine(" builder.Services.CollectModuleSettings();"); sb.AppendLine( diff --git a/framework/SimpleModule.Generator/Emitters/ModuleOptionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/ModuleOptionsEmitter.cs new file mode 100644 index 00000000..e6af5707 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/ModuleOptionsEmitter.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +/// +/// Emits extension methods on SimpleModuleOptions for each discovered IModuleOptions class +/// (e.g., ConfigureProducts()) and a RegisterModuleOptionsDefaults() helper that registers +/// IOptions<T> for all discovered options types. +/// +internal sealed class ModuleOptionsEmitter : IEmitter +{ + public void Emit(SourceProductionContext context, DiscoveryData data) + { + // Group by module to detect duplicates (diagnosed separately) + var optionsByModule = new Dictionary>(); + foreach (var opt in data.ModuleOptions) + { + if (!optionsByModule.TryGetValue(opt.ModuleName, out var list)) + { + list = new List(); + optionsByModule[opt.ModuleName] = list; + } + list.Add(opt); + } + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("using Microsoft.Extensions.DependencyInjection;"); + sb.AppendLine("using SimpleModule.Hosting;"); + sb.AppendLine(); + sb.AppendLine("namespace SimpleModule.Core;"); + sb.AppendLine(); + sb.AppendLine( + "/// " + ); + sb.AppendLine( + "/// Generated extension methods for configuring module options." + ); + sb.AppendLine("/// "); + sb.AppendLine("public static class ModuleOptionsExtensions"); + sb.AppendLine("{"); + + // Generate a Configure{ModuleName}() extension method for each unique module options class + foreach (var kvp in optionsByModule) + { + // Only emit for the first options class per module (diagnostic catches duplicates) + var opt = kvp.Value[0]; + var moduleName = opt.ModuleName; + + sb.AppendLine($" /// "); + sb.AppendLine($" /// Configures options for the {moduleName} module."); + sb.AppendLine($" /// "); + sb.AppendLine( + $" public static SimpleModuleOptions Configure{moduleName}(this SimpleModuleOptions options, System.Action<{opt.FullyQualifiedName}> configure)" + ); + sb.AppendLine(" {"); + sb.AppendLine(" return options.ConfigureModule(configure);"); + sb.AppendLine(" }"); + sb.AppendLine(); + } + + // Generate RegisterModuleOptionsDefaults method + sb.AppendLine(" /// "); + sb.AppendLine( + " /// Registers IOptions<T> defaults for all discovered module options." + ); + sb.AppendLine(" /// "); + sb.AppendLine( + " internal static void RegisterModuleOptionsDefaults(IServiceCollection services)" + ); + sb.AppendLine(" {"); + + foreach (var kvp in optionsByModule) + { + var opt = kvp.Value[0]; + sb.AppendLine( + $" services.AddOptions<{opt.FullyQualifiedName}>();" + ); + } + + sb.AppendLine(" }"); + sb.AppendLine("}"); + + context.AddSource( + "ModuleOptionsExtensions.g.cs", + SourceText.From(sb.ToString(), Encoding.UTF8) + ); + } +} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index 6a7c0f04..8ec5a2bd 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -18,6 +18,7 @@ public class ModuleDiscovererGenerator : IIncrementalGenerator new JsonResolverEmitter(), new TypeScriptDefinitionsEmitter(), new HostingExtensionsEmitter(), + new ModuleOptionsEmitter(), new HostDbContextEmitter(), new ValueConverterConventionsEmitter(), new DbContextRegistryEmitter(), diff --git a/framework/SimpleModule.Hosting/SimpleModuleOptions.cs b/framework/SimpleModule.Hosting/SimpleModuleOptions.cs index a71b2bc9..5fa9d6cc 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleOptions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleOptions.cs @@ -1,7 +1,12 @@ +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core; + namespace SimpleModule.Hosting; public class SimpleModuleOptions { + private readonly List> _moduleOptionsActions = []; + public Type? ShellComponent { get; set; } public bool EnableSwagger { get; set; } = true; @@ -9,4 +14,30 @@ public class SimpleModuleOptions public bool EnableHealthChecks { get; set; } = true; public bool EnableDevTools { get; set; } = true; + + /// + /// Configures options for a module. Called by generated Configure{Module}() extension methods. + /// + public SimpleModuleOptions ConfigureModule(Action configure) + where TOptions : class, IModuleOptions + { + _moduleOptionsActions.Add(services => services.Configure(configure)); + return this; + } + + /// + /// Registers default options and applies user overrides. Called by generated code. + /// + [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] + public void ApplyModuleOptions(IServiceCollection services, Action registerDefaults) + { + // Register IOptions defaults for all discovered options classes + registerDefaults(services); + + // Apply user-provided overrides + foreach (var action in _moduleOptionsActions) + { + action(services); + } + } } diff --git a/modules/Products/src/SimpleModule.Products/ProductsModuleOptions.cs b/modules/Products/src/SimpleModule.Products/ProductsModuleOptions.cs new file mode 100644 index 00000000..e5a32720 --- /dev/null +++ b/modules/Products/src/SimpleModule.Products/ProductsModuleOptions.cs @@ -0,0 +1,26 @@ +using SimpleModule.Core; + +namespace SimpleModule.Products; + +/// +/// Configurable options for the Products module. +/// Override defaults from the host application: +/// +/// builder.AddSimpleModule(o => +/// { +/// o.ConfigureProducts(p => p.DefaultPageSize = 20); +/// }); +/// +/// +public class ProductsModuleOptions : IModuleOptions +{ + /// + /// Default number of products per page when browsing. Default: 10. + /// + public int DefaultPageSize { get; set; } = 10; + + /// + /// Maximum allowed page size for product listing requests. Default: 100. + /// + public int MaxPageSize { get; set; } = 100; +} diff --git a/modules/Products/src/SimpleModule.Products/Views/BrowseEndpoint.cs b/modules/Products/src/SimpleModule.Products/Views/BrowseEndpoint.cs index 47c081bf..0a01d17e 100644 --- a/modules/Products/src/SimpleModule.Products/Views/BrowseEndpoint.cs +++ b/modules/Products/src/SimpleModule.Products/Views/BrowseEndpoint.cs @@ -1,6 +1,6 @@ -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; using SimpleModule.Core; using SimpleModule.Core.Inertia; using SimpleModule.Products.Contracts; @@ -14,10 +14,14 @@ public void Map(IEndpointRouteBuilder app) { app.MapGet( "/browse", - async (IProductContracts products) => + async (IProductContracts products, IOptions options) => Inertia.Render( "Products/Browse", - new { products = await products.GetAllProductsAsync() } + new + { + products = await products.GetAllProductsAsync(), + pageSize = options.Value.DefaultPageSize, + } ) ) .AllowAnonymous(); diff --git a/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs b/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs index d3c461f3..31277c9e 100644 --- a/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs +++ b/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs @@ -234,6 +234,7 @@ public void SortModules_WithDependencies_ReordersByDependency() ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, "SimpleModule.Host" ); @@ -294,6 +295,7 @@ public void SortModules_WithCycle_ReturnsOriginalOrder() ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, "SimpleModule.Host" ); @@ -366,6 +368,7 @@ public void SortModules_NoDependencies_PreservesOriginalOrder() ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, "SimpleModule.Host" ); From 1ce11ec5bd1ace5061ad8e11e73ff541e85f2291 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 28 Mar 2026 17:04:28 +0000 Subject: [PATCH 2/4] Regenerate TypeScript type definitions from build Auto-generated types.ts files updated by source generator during build. --- modules/Admin/src/SimpleModule.Admin/types.ts | 4 +++- modules/AuditLogs/src/SimpleModule.AuditLogs/types.ts | 1 + modules/FileStorage/src/SimpleModule.FileStorage/types.ts | 1 + modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts | 4 +++- modules/Orders/src/SimpleModule.Orders/types.ts | 1 + modules/PageBuilder/src/SimpleModule.PageBuilder/types.ts | 1 + modules/Products/src/SimpleModule.Products/types.ts | 1 + modules/Settings/src/SimpleModule.Settings/types.ts | 1 + modules/Users/src/SimpleModule.Users/types.ts | 1 + 9 files changed, 13 insertions(+), 2 deletions(-) diff --git a/modules/Admin/src/SimpleModule.Admin/types.ts b/modules/Admin/src/SimpleModule.Admin/types.ts index a7ed2736..140b48c4 100644 --- a/modules/Admin/src/SimpleModule.Admin/types.ts +++ b/modules/Admin/src/SimpleModule.Admin/types.ts @@ -1,5 +1,6 @@ // Auto-generated from [Dto] types — do not edit -export type AdminPermissions = {}; +export interface AdminPermissions { +} export interface AuditLogEntryDto { id: number; @@ -10,3 +11,4 @@ export interface AuditLogEntryDto { details: string; timestamp: string; } + diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/types.ts b/modules/AuditLogs/src/SimpleModule.AuditLogs/types.ts index af16cb67..c6232099 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/types.ts +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/types.ts @@ -83,3 +83,4 @@ export interface AuditStats { byAction: Record; byStatusCode: Record; } + diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/types.ts b/modules/FileStorage/src/SimpleModule.FileStorage/types.ts index 89ade8f2..97621843 100644 --- a/modules/FileStorage/src/SimpleModule.FileStorage/types.ts +++ b/modules/FileStorage/src/SimpleModule.FileStorage/types.ts @@ -8,3 +8,4 @@ export interface StoredFile { folder: string; createdAt: string; } + diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts b/modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts index 7d5dadaa..294e1680 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/types.ts @@ -1,2 +1,4 @@ // Auto-generated from [Dto] types — do not edit -export type OpenIddictPermissions = {}; +export interface OpenIddictPermissions { +} + diff --git a/modules/Orders/src/SimpleModule.Orders/types.ts b/modules/Orders/src/SimpleModule.Orders/types.ts index c2131e36..1e9249d7 100644 --- a/modules/Orders/src/SimpleModule.Orders/types.ts +++ b/modules/Orders/src/SimpleModule.Orders/types.ts @@ -21,3 +21,4 @@ export interface UpdateOrderRequest { userId: string; items: OrderItem[]; } + diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/types.ts b/modules/PageBuilder/src/SimpleModule.PageBuilder/types.ts index 38d8729f..715aae9b 100644 --- a/modules/PageBuilder/src/SimpleModule.PageBuilder/types.ts +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/types.ts @@ -68,3 +68,4 @@ export interface UpdatePageRequest { metaKeywords: string; ogImage: string; } + diff --git a/modules/Products/src/SimpleModule.Products/types.ts b/modules/Products/src/SimpleModule.Products/types.ts index 8b0b9646..bd271339 100644 --- a/modules/Products/src/SimpleModule.Products/types.ts +++ b/modules/Products/src/SimpleModule.Products/types.ts @@ -14,3 +14,4 @@ export interface UpdateProductRequest { name: string; price: number; } + diff --git a/modules/Settings/src/SimpleModule.Settings/types.ts b/modules/Settings/src/SimpleModule.Settings/types.ts index 2e9c4a0a..955564a0 100644 --- a/modules/Settings/src/SimpleModule.Settings/types.ts +++ b/modules/Settings/src/SimpleModule.Settings/types.ts @@ -65,3 +65,4 @@ export interface UpdateSettingRequest { value: string; scope: any; } + diff --git a/modules/Users/src/SimpleModule.Users/types.ts b/modules/Users/src/SimpleModule.Users/types.ts index 715c10a2..4ccbbccd 100644 --- a/modules/Users/src/SimpleModule.Users/types.ts +++ b/modules/Users/src/SimpleModule.Users/types.ts @@ -62,3 +62,4 @@ export interface UserDto { emailConfirmed: boolean; twoFactorEnabled: boolean; } + From 1932f955ec94ebb9acb64545d27146aa62036557 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 28 Mar 2026 18:10:54 +0000 Subject: [PATCH 3/4] Add configurable module options for all modules with tests Add IModuleOptions implementations for 8 modules with meaningful, production-relevant configurable values: - AuditLogs: batch size, flush interval, retention days, check interval - Admin: users page size - FileStorage: max file size (MB), allowed extensions - Orders: default/max page size - PageBuilder: max title/slug lengths - Users: password policy (min length, digit/case requirements), lockout - Settings: cache duration for settings and public menu - Products: default/max page size (from prior commit) Wire options into module code replacing hardcoded constants: - AuditWriterService reads batch size and flush interval from options - AuditRetentionService reads retention days and check interval - Admin UsersEndpoint reads page size from options - Users module bridges UsersModuleOptions into Identity options - SettingsService and PublicMenuService read cache duration from options Add comprehensive tests: - 7 generator tests: discovery, extension method generation, defaults registration, multi-module, empty case, SM0044 diagnostic, hosting wiring - 9 integration tests: verify all module options resolve with correct defaults via IOptions from the real DI container - 1 override test: verify all 8 module options can be overridden via services.Configure() (simulating host app customization) --- .../SimpleModule.Admin/AdminModuleOptions.cs | 14 ++ .../Views/Admin/UsersEndpoint.cs | 15 +- .../AuditLogsModuleOptions.cs | 29 +++ .../Pipeline/AuditWriterService.cs | 12 +- .../Retention/AuditRetentionService.cs | 10 +- .../FileStorageModule.cs | 1 + .../FileStorageModuleOptions.cs | 21 ++ .../OrdersModuleOptions.cs | 19 ++ .../PageBuilderModuleOptions.cs | 19 ++ .../Services/PublicMenuService.cs | 12 +- .../SettingsModuleOptions.cs | 14 ++ .../SimpleModule.Settings/SettingsService.cs | 5 +- .../Unit/PublicMenuServiceTests.cs | 2 +- .../Unit/SettingsServiceTests.cs | 8 +- .../ApplyUsersModuleOptions.cs | 32 +++ .../src/SimpleModule.Users/UsersModule.cs | 17 +- .../SimpleModule.Users/UsersModuleOptions.cs | 44 ++++ .../Infrastructure/ModuleOptionsTests.cs | 216 +++++++++++++++++ .../ModuleOptionsEmitterTests.cs | 227 ++++++++++++++++++ 19 files changed, 682 insertions(+), 35 deletions(-) create mode 100644 modules/Admin/src/SimpleModule.Admin/AdminModuleOptions.cs create mode 100644 modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModuleOptions.cs create mode 100644 modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModuleOptions.cs create mode 100644 modules/Orders/src/SimpleModule.Orders/OrdersModuleOptions.cs create mode 100644 modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModuleOptions.cs create mode 100644 modules/Settings/src/SimpleModule.Settings/SettingsModuleOptions.cs create mode 100644 modules/Users/src/SimpleModule.Users/ApplyUsersModuleOptions.cs create mode 100644 modules/Users/src/SimpleModule.Users/UsersModuleOptions.cs create mode 100644 tests/SimpleModule.Core.Tests/Infrastructure/ModuleOptionsTests.cs create mode 100644 tests/SimpleModule.Generator.Tests/ModuleOptionsEmitterTests.cs diff --git a/modules/Admin/src/SimpleModule.Admin/AdminModuleOptions.cs b/modules/Admin/src/SimpleModule.Admin/AdminModuleOptions.cs new file mode 100644 index 00000000..94cc8482 --- /dev/null +++ b/modules/Admin/src/SimpleModule.Admin/AdminModuleOptions.cs @@ -0,0 +1,14 @@ +using SimpleModule.Core; + +namespace SimpleModule.Admin; + +/// +/// Configurable options for the Admin module. +/// +public class AdminModuleOptions : IModuleOptions +{ + /// + /// Number of users to display per page in the admin user list. Default: 20. + /// + public int UsersPageSize { get; set; } = 20; +} diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs index b4450968..329d696a 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; using SimpleModule.Core; using SimpleModule.Core.Inertia; using SimpleModule.Users.Contracts; @@ -9,16 +10,20 @@ namespace SimpleModule.Admin.Views.Admin; [ViewPage("Admin/Admin/Users")] public class UsersEndpoint : IViewEndpoint { - private const int PageSize = 20; - public void Map(IEndpointRouteBuilder app) { app.MapGet( "/users", - async (IUserAdminContracts userAdmin, string? search, int page = 1) => + async ( + IUserAdminContracts userAdmin, + IOptions options, + string? search, + int page = 1 + ) => { - var result = await userAdmin.GetUsersPagedAsync(search, page, PageSize); - var totalPages = (int)Math.Ceiling((double)result.TotalCount / PageSize); + var pageSize = options.Value.UsersPageSize; + var result = await userAdmin.GetUsersPagedAsync(search, page, pageSize); + var totalPages = (int)Math.Ceiling((double)result.TotalCount / pageSize); return Inertia.Render( "Admin/Admin/Users", diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModuleOptions.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModuleOptions.cs new file mode 100644 index 00000000..193c84af --- /dev/null +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/AuditLogsModuleOptions.cs @@ -0,0 +1,29 @@ +using SimpleModule.Core; + +namespace SimpleModule.AuditLogs; + +/// +/// Configurable options for the AuditLogs module. +/// +public class AuditLogsModuleOptions : IModuleOptions +{ + /// + /// Maximum number of audit entries to batch before flushing to the database. Default: 100. + /// + public int WriterBatchSize { get; set; } = 100; + + /// + /// How long to wait before flushing an incomplete batch. Default: 2 seconds. + /// + public TimeSpan WriterFlushInterval { get; set; } = TimeSpan.FromSeconds(2); + + /// + /// Default number of days to retain audit log entries before cleanup. Default: 90. + /// + public int RetentionDays { get; set; } = 90; + + /// + /// How often the retention cleanup job runs. Default: 24 hours. + /// + public TimeSpan RetentionCheckInterval { get; set; } = TimeSpan.FromHours(24); +} diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs index a2e34acc..2673648a 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Pipeline/AuditWriterService.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using SimpleModule.AuditLogs.Contracts; namespace SimpleModule.AuditLogs.Pipeline; @@ -8,16 +9,15 @@ namespace SimpleModule.AuditLogs.Pipeline; public sealed partial class AuditWriterService( AuditChannel channel, IServiceScopeFactory scopeFactory, + IOptions moduleOptions, ILogger logger ) : BackgroundService { - private const int BatchSize = 100; - private static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(2); - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { LogStarted(logger); - var batch = new List(BatchSize); + var opts = moduleOptions.Value; + var batch = new List(opts.WriterBatchSize); while (!stoppingToken.IsCancellationRequested) { @@ -26,10 +26,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) if (await channel.Reader.WaitToReadAsync(stoppingToken)) { batch.Clear(); - var deadline = DateTimeOffset.UtcNow.Add(FlushInterval); + var deadline = DateTimeOffset.UtcNow.Add(opts.WriterFlushInterval); while ( - batch.Count < BatchSize + batch.Count < opts.WriterBatchSize && DateTimeOffset.UtcNow < deadline && channel.Reader.TryRead(out var entry) ) diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Retention/AuditRetentionService.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Retention/AuditRetentionService.cs index df6ae14e..a6d5c4e0 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Retention/AuditRetentionService.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Retention/AuditRetentionService.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using SimpleModule.AuditLogs.Contracts; using SimpleModule.Core.Settings; using SimpleModule.Settings.Contracts; @@ -9,15 +10,16 @@ namespace SimpleModule.AuditLogs.Retention; public sealed partial class AuditRetentionService( IServiceScopeFactory scopeFactory, + IOptions moduleOptions, ILogger logger ) : BackgroundService { - private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(24); - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + var checkInterval = moduleOptions.Value.RetentionCheckInterval; + while (!stoppingToken.IsCancellationRequested) { try @@ -35,7 +37,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) LogError(logger, ex); } - await Task.Delay(CheckInterval, stoppingToken); + await Task.Delay(checkInterval, stoppingToken); } } @@ -58,7 +60,7 @@ private async Task RunCleanupAsync(CancellationToken ct) } } - var retentionDays = 90; + var retentionDays = moduleOptions.Value.RetentionDays; if (settings is not null) { var days = await settings.GetSettingAsync( diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModule.cs b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModule.cs index aa74c463..986532c3 100644 --- a/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModule.cs +++ b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModule.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Menu; diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModuleOptions.cs b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModuleOptions.cs new file mode 100644 index 00000000..77889411 --- /dev/null +++ b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageModuleOptions.cs @@ -0,0 +1,21 @@ +using SimpleModule.Core; + +namespace SimpleModule.FileStorage; + +/// +/// Configurable options for the FileStorage module. +/// +public class FileStorageModuleOptions : IModuleOptions +{ + /// + /// Maximum file size allowed for uploads, in megabytes. Default: 50. + /// + public int MaxFileSizeMb { get; set; } = 50; + + /// + /// Comma-separated list of allowed file extensions for uploads. + /// Default: ".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.zip" + /// + public string AllowedExtensions { get; set; } = + ".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.zip"; +} diff --git a/modules/Orders/src/SimpleModule.Orders/OrdersModuleOptions.cs b/modules/Orders/src/SimpleModule.Orders/OrdersModuleOptions.cs new file mode 100644 index 00000000..26eaceb7 --- /dev/null +++ b/modules/Orders/src/SimpleModule.Orders/OrdersModuleOptions.cs @@ -0,0 +1,19 @@ +using SimpleModule.Core; + +namespace SimpleModule.Orders; + +/// +/// Configurable options for the Orders module. +/// +public class OrdersModuleOptions : IModuleOptions +{ + /// + /// Default number of recent orders to show in summary views. Default: 10. + /// + public int DefaultPageSize { get; set; } = 10; + + /// + /// Maximum allowed page size for order listing requests. Default: 100. + /// + public int MaxPageSize { get; set; } = 100; +} diff --git a/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModuleOptions.cs b/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModuleOptions.cs new file mode 100644 index 00000000..cc50c131 --- /dev/null +++ b/modules/PageBuilder/src/SimpleModule.PageBuilder/PageBuilderModuleOptions.cs @@ -0,0 +1,19 @@ +using SimpleModule.Core; + +namespace SimpleModule.PageBuilder; + +/// +/// Configurable options for the PageBuilder module. +/// +public class PageBuilderModuleOptions : IModuleOptions +{ + /// + /// Maximum length for page titles. Default: 200. + /// + public int MaxTitleLength { get; set; } = 200; + + /// + /// Maximum length for page slugs. Default: 200. + /// + public int MaxSlugLength { get; set; } = 200; +} diff --git a/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs b/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs index de5222fd..25cf35d4 100644 --- a/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs +++ b/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs @@ -1,16 +1,20 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; using SimpleModule.Core.Menu; using SimpleModule.Settings.Contracts; using SimpleModule.Settings.Entities; namespace SimpleModule.Settings.Services; -public sealed class PublicMenuService(SettingsDbContext db, IMemoryCache cache) : IPublicMenuProvider +public sealed class PublicMenuService( + SettingsDbContext db, + IMemoryCache cache, + IOptions moduleOptions +) : IPublicMenuProvider { private const string MenuTreeCacheKey = "PublicMenu_Tree"; private const string HomePageCacheKey = "PublicMenu_Home"; - private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60); public async Task> GetMenuTreeAsync() { @@ -26,7 +30,7 @@ public async Task> GetMenuTreeAsync() .ToListAsync(); var tree = BuildPublicTree(entities, parentId: null); - cache.Set(MenuTreeCacheKey, tree, CacheDuration); + cache.Set(MenuTreeCacheKey, tree, moduleOptions.Value.CacheDuration); return tree; } @@ -45,7 +49,7 @@ public async Task> GetMenuTreeAsync() .FirstOrDefaultAsync(); var url = entity is not null ? (entity.Url ?? entity.PageRoute) : null; - cache.Set(HomePageCacheKey, url, CacheDuration); + cache.Set(HomePageCacheKey, url, moduleOptions.Value.CacheDuration); return url; } diff --git a/modules/Settings/src/SimpleModule.Settings/SettingsModuleOptions.cs b/modules/Settings/src/SimpleModule.Settings/SettingsModuleOptions.cs new file mode 100644 index 00000000..c15f4d39 --- /dev/null +++ b/modules/Settings/src/SimpleModule.Settings/SettingsModuleOptions.cs @@ -0,0 +1,14 @@ +using SimpleModule.Core; + +namespace SimpleModule.Settings; + +/// +/// Configurable options for the Settings module. +/// +public class SettingsModuleOptions : IModuleOptions +{ + /// + /// How long settings values are cached in memory before being re-read from the database. Default: 60 seconds. + /// + public TimeSpan CacheDuration { get; set; } = TimeSpan.FromSeconds(60); +} diff --git a/modules/Settings/src/SimpleModule.Settings/SettingsService.cs b/modules/Settings/src/SimpleModule.Settings/SettingsService.cs index aed25070..99924749 100644 --- a/modules/Settings/src/SimpleModule.Settings/SettingsService.cs +++ b/modules/Settings/src/SimpleModule.Settings/SettingsService.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using SimpleModule.Core.Settings; using SimpleModule.Settings.Contracts; using SimpleModule.Settings.Entities; @@ -12,10 +13,10 @@ public sealed partial class SettingsService( SettingsDbContext db, ISettingsDefinitionRegistry definitions, IMemoryCache cache, + IOptions moduleOptions, ILogger logger ) : ISettingsContracts { - private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60); public async Task GetSettingAsync( string key, @@ -36,7 +37,7 @@ ILogger logger && (scope == SettingScope.User ? s.UserId == userId : s.UserId == null) ); - cache.Set(cacheKey, entity?.Value, CacheDuration); + cache.Set(cacheKey, entity?.Value, moduleOptions.Value.CacheDuration); return entity?.Value; } diff --git a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/PublicMenuServiceTests.cs b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/PublicMenuServiceTests.cs index 9eee9499..38386bf7 100644 --- a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/PublicMenuServiceTests.cs +++ b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/PublicMenuServiceTests.cs @@ -28,7 +28,7 @@ public PublicMenuServiceTests() _db.Database.EnsureCreated(); _cache = new MemoryCache(new MemoryCacheOptions()); - _service = new PublicMenuService(_db, _cache); + _service = new PublicMenuService(_db, _cache, Options.Create(new SettingsModuleOptions())); } [Fact] diff --git a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs index a2660629..00cfede9 100644 --- a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs +++ b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs @@ -38,7 +38,13 @@ public SettingsServiceTests() ]); _cache = new MemoryCache(new MemoryCacheOptions()); - _service = new SettingsService(_db, registry, _cache, NullLogger.Instance); + _service = new SettingsService( + _db, + registry, + _cache, + Options.Create(new SettingsModuleOptions()), + NullLogger.Instance + ); } [Fact] diff --git a/modules/Users/src/SimpleModule.Users/ApplyUsersModuleOptions.cs b/modules/Users/src/SimpleModule.Users/ApplyUsersModuleOptions.cs new file mode 100644 index 00000000..dd04b246 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/ApplyUsersModuleOptions.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +namespace SimpleModule.Users; + +/// +/// Bridges into ASP.NET Identity's , +/// allowing the host app to control password and lockout policies via the module options pattern. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1812:Avoid uninstantiated internal classes", + Justification = "Instantiated by DI" +)] +internal sealed class ApplyUsersModuleOptions(IOptions moduleOptions) + : IPostConfigureOptions +{ + public void PostConfigure(string? name, IdentityOptions options) + { + var opts = moduleOptions.Value; + + options.Password.RequiredLength = opts.PasswordMinLength; + options.Password.RequireDigit = opts.PasswordRequireDigit; + options.Password.RequireUppercase = opts.PasswordRequireUppercase; + options.Password.RequireLowercase = opts.PasswordRequireLowercase; + options.Password.RequireNonAlphanumeric = opts.PasswordRequireNonAlphanumeric; + options.Lockout.DefaultLockoutTimeSpan = opts.LockoutDuration; + options.Lockout.MaxFailedAccessAttempts = opts.MaxFailedAccessAttempts; + options.User.RequireUniqueEmail = true; + options.SignIn.RequireConfirmedEmail = false; + } +} diff --git a/modules/Users/src/SimpleModule.Users/UsersModule.cs b/modules/Users/src/SimpleModule.Users/UsersModule.cs index ef57ab43..beb80cfd 100644 --- a/modules/Users/src/SimpleModule.Users/UsersModule.cs +++ b/modules/Users/src/SimpleModule.Users/UsersModule.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using SimpleModule.Core; using SimpleModule.Core.Menu; using SimpleModule.Database; @@ -17,21 +18,13 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddModuleDbContext(configuration, UsersConstants.ModuleName); services - .AddIdentity(options => - { - options.Password.RequiredLength = 8; - options.Password.RequireDigit = true; - options.Password.RequireUppercase = true; - options.Password.RequireLowercase = true; - options.Password.RequireNonAlphanumeric = false; - options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); - options.Lockout.MaxFailedAccessAttempts = 5; - options.User.RequireUniqueEmail = true; - options.SignIn.RequireConfirmedEmail = false; - }) + .AddIdentity() .AddEntityFrameworkStores() .AddDefaultTokenProviders(); + // Bridge UsersModuleOptions into ASP.NET Identity options + services.AddSingleton, ApplyUsersModuleOptions>(); + services.AddHostedService(); services.AddSingleton, ConsoleEmailSender>(); } diff --git a/modules/Users/src/SimpleModule.Users/UsersModuleOptions.cs b/modules/Users/src/SimpleModule.Users/UsersModuleOptions.cs new file mode 100644 index 00000000..81c30298 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/UsersModuleOptions.cs @@ -0,0 +1,44 @@ +using SimpleModule.Core; + +namespace SimpleModule.Users; + +/// +/// Configurable options for the Users module. +/// +public class UsersModuleOptions : IModuleOptions +{ + /// + /// Minimum required password length. Default: 8. + /// + public int PasswordMinLength { get; set; } = 8; + + /// + /// Whether passwords must contain at least one digit. Default: true. + /// + public bool PasswordRequireDigit { get; set; } = true; + + /// + /// Whether passwords must contain at least one uppercase letter. Default: true. + /// + public bool PasswordRequireUppercase { get; set; } = true; + + /// + /// Whether passwords must contain at least one lowercase letter. Default: true. + /// + public bool PasswordRequireLowercase { get; set; } = true; + + /// + /// Whether passwords must contain at least one non-alphanumeric character. Default: false. + /// + public bool PasswordRequireNonAlphanumeric { get; set; } + + /// + /// Maximum number of failed login attempts before account lockout. Default: 5. + /// + public int MaxFailedAccessAttempts { get; set; } = 5; + + /// + /// Duration of account lockout after exceeding max failed attempts. Default: 5 minutes. + /// + public TimeSpan LockoutDuration { get; set; } = TimeSpan.FromMinutes(5); +} diff --git a/tests/SimpleModule.Core.Tests/Infrastructure/ModuleOptionsTests.cs b/tests/SimpleModule.Core.Tests/Infrastructure/ModuleOptionsTests.cs new file mode 100644 index 00000000..66748f46 --- /dev/null +++ b/tests/SimpleModule.Core.Tests/Infrastructure/ModuleOptionsTests.cs @@ -0,0 +1,216 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using SimpleModule.Admin; +using SimpleModule.AuditLogs; +using SimpleModule.FileStorage; +using SimpleModule.Orders; +using SimpleModule.PageBuilder; +using SimpleModule.Products; +using SimpleModule.Settings; +using SimpleModule.Tests.Shared.Fixtures; +using SimpleModule.Users; + +namespace SimpleModule.Core.Tests.Infrastructure; + +/// +/// Verifies that all module options are registered via IOptions<T> and can be +/// resolved with their default values. Also verifies that overrides from the host +/// app's Configure{Module}() calls are applied correctly. +/// +public class ModuleOptionsTests : IClassFixture +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public ModuleOptionsTests(SimpleModuleWebApplicationFactory factory) => _factory = factory; + + // ── Default values ───────────────────────────────────────────────── + + [Fact] + public void ProductsModuleOptions_IsRegistered_WithDefaults() + { + using var scope = _factory.Services.CreateScope(); + var options = scope.ServiceProvider.GetRequiredService>(); + + options.Value.DefaultPageSize.Should().Be(10); + options.Value.MaxPageSize.Should().Be(100); + } + + [Fact] + public void AuditLogsModuleOptions_IsRegistered_WithDefaults() + { + using var scope = _factory.Services.CreateScope(); + var options = scope.ServiceProvider.GetRequiredService>(); + + options.Value.WriterBatchSize.Should().Be(100); + options.Value.WriterFlushInterval.Should().Be(TimeSpan.FromSeconds(2)); + options.Value.RetentionDays.Should().Be(90); + options.Value.RetentionCheckInterval.Should().Be(TimeSpan.FromHours(24)); + } + + [Fact] + public void AdminModuleOptions_IsRegistered_WithDefaults() + { + using var scope = _factory.Services.CreateScope(); + var options = scope.ServiceProvider.GetRequiredService>(); + + options.Value.UsersPageSize.Should().Be(20); + } + + [Fact] + public void FileStorageModuleOptions_IsRegistered_WithDefaults() + { + using var scope = _factory.Services.CreateScope(); + var options = + scope.ServiceProvider.GetRequiredService>(); + + options.Value.MaxFileSizeMb.Should().Be(50); + options.Value.AllowedExtensions.Should().Contain(".jpg"); + } + + [Fact] + public void PageBuilderModuleOptions_IsRegistered_WithDefaults() + { + using var scope = _factory.Services.CreateScope(); + var options = + scope.ServiceProvider.GetRequiredService>(); + + options.Value.MaxTitleLength.Should().Be(200); + options.Value.MaxSlugLength.Should().Be(200); + } + + [Fact] + public void UsersModuleOptions_IsRegistered_WithDefaults() + { + using var scope = _factory.Services.CreateScope(); + var options = scope.ServiceProvider.GetRequiredService>(); + + options.Value.PasswordMinLength.Should().Be(8); + options.Value.PasswordRequireDigit.Should().BeTrue(); + options.Value.PasswordRequireUppercase.Should().BeTrue(); + options.Value.PasswordRequireLowercase.Should().BeTrue(); + options.Value.PasswordRequireNonAlphanumeric.Should().BeFalse(); + options.Value.MaxFailedAccessAttempts.Should().Be(5); + options.Value.LockoutDuration.Should().Be(TimeSpan.FromMinutes(5)); + } + + [Fact] + public void SettingsModuleOptions_IsRegistered_WithDefaults() + { + using var scope = _factory.Services.CreateScope(); + var options = scope.ServiceProvider.GetRequiredService>(); + + options.Value.CacheDuration.Should().Be(TimeSpan.FromSeconds(60)); + } + + [Fact] + public void OrdersModuleOptions_IsRegistered_WithDefaults() + { + using var scope = _factory.Services.CreateScope(); + var options = scope.ServiceProvider.GetRequiredService>(); + + options.Value.DefaultPageSize.Should().Be(10); + options.Value.MaxPageSize.Should().Be(100); + } + + // ── Override test ────────────────────────────────────────────────── + + [Fact] + public void ModuleOptions_CanBeOverridden_ViaServicesConfigure() + { + // Simulate what a host app would do: services.Configure(...) + // We test this by creating a custom factory with overrides + var services = new ServiceCollection(); + + // Register defaults (what the generator does) + services.AddOptions(); + services.AddOptions(); + services.AddOptions(); + services.AddOptions(); + services.AddOptions(); + services.AddOptions(); + services.AddOptions(); + services.AddOptions(); + + // Override (what the host app does via ConfigureProducts, etc.) + services.Configure(o => + { + o.DefaultPageSize = 25; + o.MaxPageSize = 50; + }); + services.Configure(o => + { + o.WriterBatchSize = 200; + o.RetentionDays = 30; + }); + services.Configure(o => o.UsersPageSize = 50); + services.Configure(o => o.DefaultPageSize = 20); + services.Configure(o => + { + o.PasswordMinLength = 12; + o.MaxFailedAccessAttempts = 3; + }); + services.Configure(o => + o.CacheDuration = TimeSpan.FromMinutes(5) + ); + services.Configure(o => + { + o.MaxFileSizeMb = 100; + o.AllowedExtensions = ".pdf,.zip"; + }); + services.Configure(o => + { + o.MaxTitleLength = 300; + o.MaxSlugLength = 300; + }); + + using var sp = services.BuildServiceProvider(); + + sp.GetRequiredService>() + .Value.DefaultPageSize.Should() + .Be(25); + sp.GetRequiredService>() + .Value.MaxPageSize.Should() + .Be(50); + + sp.GetRequiredService>() + .Value.WriterBatchSize.Should() + .Be(200); + sp.GetRequiredService>() + .Value.RetentionDays.Should() + .Be(30); + + sp.GetRequiredService>() + .Value.UsersPageSize.Should() + .Be(50); + + sp.GetRequiredService>() + .Value.DefaultPageSize.Should() + .Be(20); + + sp.GetRequiredService>() + .Value.PasswordMinLength.Should() + .Be(12); + sp.GetRequiredService>() + .Value.MaxFailedAccessAttempts.Should() + .Be(3); + + sp.GetRequiredService>() + .Value.CacheDuration.Should() + .Be(TimeSpan.FromMinutes(5)); + + sp.GetRequiredService>() + .Value.MaxFileSizeMb.Should() + .Be(100); + sp.GetRequiredService>() + .Value.AllowedExtensions.Should() + .Be(".pdf,.zip"); + + sp.GetRequiredService>() + .Value.MaxTitleLength.Should() + .Be(300); + sp.GetRequiredService>() + .Value.MaxSlugLength.Should() + .Be(300); + } +} diff --git a/tests/SimpleModule.Generator.Tests/ModuleOptionsEmitterTests.cs b/tests/SimpleModule.Generator.Tests/ModuleOptionsEmitterTests.cs new file mode 100644 index 00000000..d784263f --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/ModuleOptionsEmitterTests.cs @@ -0,0 +1,227 @@ +using FluentAssertions; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class ModuleOptionsEmitterTests +{ + [Fact] + public void Module_WithOptionsClass_GeneratesConfigureExtensionMethod() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products", RoutePrefix = "/api/products")] + public class ProductsModule : IModule { } + } + + namespace TestApp + { + public class ProductsModuleOptions : IModuleOptions + { + public int MaxPageSize { get; set; } = 100; + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var generated = GetGeneratedSource(result, "ModuleOptionsExtensions.g.cs"); + + generated + .Should() + .Contain("ConfigureProducts(this SimpleModuleOptions options"); + generated + .Should() + .Contain("global::TestApp.ProductsModuleOptions"); + generated + .Should() + .Contain("options.ConfigureModule(configure)"); + } + + [Fact] + public void Module_WithOptionsClass_RegistersDefaultsInRegisterMethod() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products", RoutePrefix = "/api/products")] + public class ProductsModule : IModule { } + + public class ProductsModuleOptions : IModuleOptions + { + public int MaxPageSize { get; set; } = 100; + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var generated = GetGeneratedSource(result, "ModuleOptionsExtensions.g.cs"); + + generated + .Should() + .Contain("RegisterModuleOptionsDefaults"); + generated + .Should() + .Contain("services.AddOptions()"); + } + + [Fact] + public void MultipleModules_WithOptions_GeneratesConfigureForEach() + { + // In a single-assembly compilation, all options are discovered for all + // modules in that assembly. Both Configure methods are generated. + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products")] + public class ProductsModule : IModule { } + + public class ProductsModuleOptions : IModuleOptions + { + public int PageSize { get; set; } = 10; + } + + [Module("Orders")] + public class OrdersModule : IModule { } + + public class OrdersModuleOptions : IModuleOptions + { + public int MaxItems { get; set; } = 50; + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var generated = GetGeneratedSource(result, "ModuleOptionsExtensions.g.cs"); + + // Both module Configure methods should exist + generated.Should().Contain("ConfigureProducts("); + generated.Should().Contain("ConfigureOrders("); + } + + [Fact] + public void Module_WithNoOptionsClass_GeneratesEmptyExtensions() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products")] + public class ProductsModule : IModule { } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var generated = GetGeneratedSource(result, "ModuleOptionsExtensions.g.cs"); + + generated.Should().Contain("ModuleOptionsExtensions"); + generated.Should().NotContain("ConfigureProducts("); + } + + [Fact] + public void SM0044_MultipleOptionsForSameModule_ReportsDiagnostic() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products")] + public class ProductsModule : IModule { } + + public class ProductsModuleOptions : IModuleOptions + { + public int PageSize { get; set; } + } + + public class ProductsExtraOptions : IModuleOptions + { + public bool Feature { get; set; } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().Contain(d => d.Id == "SM0044"); + } + + [Fact] + public void SM0044_SingleOptionsPerModule_NoDiagnostic() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products")] + public class ProductsModule : IModule { } + + public class ProductsModuleOptions : IModuleOptions + { + public int PageSize { get; set; } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var (_, diagnostics) = GeneratorTestHelper.RunGeneratorWithDiagnostics(compilation); + + diagnostics.Should().NotContain(d => d.Id == "SM0044"); + } + + [Fact] + public void HostingExtensions_CallsApplyModuleOptions() + { + var source = """ + using SimpleModule.Core; + + namespace TestApp + { + [Module("Products")] + public class ProductsModule : IModule { } + + public class ProductsModuleOptions : IModuleOptions + { + public int PageSize { get; set; } + } + } + """; + + var compilation = GeneratorTestHelper.CreateCompilation(source); + var result = GeneratorTestHelper.RunGenerator(compilation); + + var generated = GetGeneratedSource(result, "HostingExtensions.g.cs"); + + generated + .Should() + .Contain("ApplyModuleOptions"); + } + + private static string GetGeneratedSource( + Microsoft.CodeAnalysis.GeneratorDriverRunResult result, + string fileName + ) + { + return result + .GeneratedTrees.First(t => t.FilePath.EndsWith(fileName, StringComparison.Ordinal)) + .GetText() + .ToString(); + } +} From 14acb60b2cd36004a0d9d865a838e6d79302099e Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 28 Mar 2026 18:57:58 +0000 Subject: [PATCH 4/4] Simplify module options code: extract helpers, remove dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address code review findings: - Extract FindConcreteClassesImplementing() shared namespace walker to eliminate duplication between FindModuleOptionsClasses and similar recursive discovery methods - Extract ModuleOptionsRecord.GroupByModule() to deduplicate identical dictionary-building logic in DiagnosticEmitter and ModuleOptionsEmitter - Remove ModuleOptionsInfo mutable class — build ModuleOptionsRecord directly during discovery (no mutable accumulation needed) - Remove unreachable SM0045 diagnostic — discovery only scans known module assemblies, so orphan options classes cannot occur - Use ScanModuleAssemblies helper for module options discovery to get assembly-level deduplication (consistent with interceptor discovery) - Align IModuleOptions pragma comment style with IModulePermissions --- CLAUDE.md | 2 +- docs/CONSTITUTION.md | 3 - framework/SimpleModule.Core/IModuleOptions.cs | 2 +- .../AnalyzerReleases.Unshipped.md | 1 - .../Discovery/DiscoveryData.cs | 26 +++++-- .../Discovery/SymbolDiscovery.cs | 69 +++++++++++-------- .../Emitters/DiagnosticEmitter.cs | 42 +---------- .../Emitters/ModuleOptionsEmitter.cs | 14 +--- .../ModuleOptionsEmitterTests.cs | 16 ++--- 9 files changed, 69 insertions(+), 106 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 13b21006..0f844243 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ See [docs/CONSTITUTION.md](docs/CONSTITUTION.md) for the authoritative reference - Module boundaries, dependencies, and data ownership - Communication patterns (contracts and events) - Endpoint, frontend, and authorization rules -- Compiler-enforced diagnostics (SM0001-SM0045) +- Compiler-enforced diagnostics (SM0001-SM0044) - Framework contributor guidelines ## Key Constraints diff --git a/docs/CONSTITUTION.md b/docs/CONSTITUTION.md index ba409d6f..61ede4e1 100644 --- a/docs/CONSTITUTION.md +++ b/docs/CONSTITUTION.md @@ -76,8 +76,6 @@ public class BrowseEndpoint(IOptions options) : IViewEndp **Rules:** - At most one `IModuleOptions` class per module (SM0044 warns on duplicates). - Options classes may live in the module assembly or its Contracts assembly. -- SM0045 warns if an options class cannot be matched to a known module. - ### What a Module Must Never Expose - Entity classes @@ -399,7 +397,6 @@ All SM diagnostics are emitted by the Roslyn source generator at compile time. ` | SM0040 | Error | No duplicate module names | | SM0043 | Warning | Module must override at least one IModule method | | SM0044 | Warning | Multiple IModuleOptions for same module | -| SM0045 | Warning | IModuleOptions class not associated with any module | ### Dependencies diff --git a/framework/SimpleModule.Core/IModuleOptions.cs b/framework/SimpleModule.Core/IModuleOptions.cs index 55574df1..2af0b541 100644 --- a/framework/SimpleModule.Core/IModuleOptions.cs +++ b/framework/SimpleModule.Core/IModuleOptions.cs @@ -16,6 +16,6 @@ namespace SimpleModule.Core; /// /// Module code reads configured values via IOptions<TOptions> injection. /// -#pragma warning disable CA1040 // Avoid empty interfaces — marker interface by design +#pragma warning disable CA1040 // Avoid empty interfaces public interface IModuleOptions; #pragma warning restore CA1040 diff --git a/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md b/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md index 8aecc408..535d0cc3 100644 --- a/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md +++ b/framework/SimpleModule.Generator/AnalyzerReleases.Unshipped.md @@ -31,4 +31,3 @@ SM0041 | SimpleModule.Generator | Warning | View page name does not match module SM0042 | SimpleModule.Generator | Error | Module has view endpoints but no ViewPrefix SM0043 | SimpleModule.Generator | Warning | Module does not override any IModule methods SM0044 | SimpleModule.Generator | Warning | Multiple IModuleOptions for same module -SM0045 | SimpleModule.Generator | Warning | IModuleOptions class not associated with any module diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs index edf0cec5..da542102 100644 --- a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs @@ -319,7 +319,25 @@ public override int GetHashCode() internal readonly record struct ModuleOptionsRecord( string FullyQualifiedName, string ModuleName -); +) +{ + internal static Dictionary> GroupByModule( + ImmutableArray options + ) + { + var result = new Dictionary>(); + foreach (var opt in options) + { + if (!result.TryGetValue(opt.ModuleName, out var list)) + { + list = new List(); + result[opt.ModuleName] = list; + } + list.Add(opt); + } + return result; + } +} internal readonly record struct VogenValueObjectRecord( string TypeFqn, @@ -439,10 +457,4 @@ internal sealed class InterceptorInfo public List ConstructorParamTypeFqns { get; set; } = new(); } -internal sealed class ModuleOptionsInfo -{ - public string FullyQualifiedName { get; set; } = ""; - public string ModuleName { get; set; } = ""; -} - #endregion diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index f2b944e2..29f8e2f0 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -396,25 +396,23 @@ is not IAssemblySymbol assemblySymbol ); // Step 3f: Find IModuleOptions implementors in module and contracts assemblies - var moduleOptionsList = new List(); + var moduleOptionsList = new List(); var moduleOptionsSymbol = compilation.GetTypeByMetadataName( "SimpleModule.Core.IModuleOptions" ); if (moduleOptionsSymbol is not null) { - foreach (var module in modules) - { - if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol)) - continue; - - var moduleAssembly = typeSymbol.ContainingAssembly; - FindModuleOptionsClasses( - moduleAssembly.GlobalNamespace, - moduleOptionsSymbol, - module.ModuleName, - moduleOptionsList - ); - } + ScanModuleAssemblies( + modules, + moduleSymbols, + (assembly, module) => + FindModuleOptionsClasses( + assembly.GlobalNamespace, + moduleOptionsSymbol, + module.ModuleName, + moduleOptionsList + ) + ); // Also scan contracts assemblies for module options classes foreach (var kvp in contractsAssemblySymbols) @@ -573,9 +571,7 @@ is not IAssemblySymbol assemblySymbol )) .ToImmutableArray(), vogenValueObjects.ToImmutableArray(), - moduleOptionsList - .Select(o => new ModuleOptionsRecord(o.FullyQualifiedName, o.ModuleName)) - .ToImmutableArray(), + moduleOptionsList.ToImmutableArray(), hostAssemblyName ); } @@ -1270,32 +1266,47 @@ private static void FindModuleOptionsClasses( INamespaceSymbol namespaceSymbol, INamedTypeSymbol moduleOptionsSymbol, string moduleName, - List results + List results + ) + { + FindConcreteClassesImplementing( + namespaceSymbol, + moduleOptionsSymbol, + typeSymbol => + results.Add( + new ModuleOptionsRecord( + typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + moduleName + ) + ) + ); + } + + /// + /// Recursively walks namespaces and invokes for each + /// concrete (non-abstract, non-static) class that implements the given interface. + /// + private static void FindConcreteClassesImplementing( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol interfaceSymbol, + Action onMatch ) { foreach (var member in namespaceSymbol.GetMembers()) { if (member is INamespaceSymbol childNs) { - FindModuleOptionsClasses(childNs, moduleOptionsSymbol, moduleName, results); + FindConcreteClassesImplementing(childNs, interfaceSymbol, onMatch); } else if ( member is INamedTypeSymbol typeSymbol && typeSymbol.TypeKind == TypeKind.Class && !typeSymbol.IsAbstract && !typeSymbol.IsStatic - && ImplementsInterface(typeSymbol, moduleOptionsSymbol) + && ImplementsInterface(typeSymbol, interfaceSymbol) ) { - results.Add( - new ModuleOptionsInfo - { - FullyQualifiedName = typeSymbol.ToDisplayString( - SymbolDisplayFormat.FullyQualifiedFormat - ), - ModuleName = moduleName, - } - ); + onMatch(typeSymbol); } } } diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index 1c2e4927..53dd2d83 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -269,15 +269,6 @@ internal sealed class DiagnosticEmitter : IEmitter isEnabledByDefault: true ); - private static readonly DiagnosticDescriptor ModuleOptionsOrphan = new( - id: "SM0045", - title: "IModuleOptions class not associated with any module", - messageFormat: "Options class '{0}' implements IModuleOptions but is associated with module '{1}' which was not found. Ensure the options class is in the same assembly as its module or the module's contracts assembly.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - public void Emit(SourceProductionContext context, DiscoveryData data) { // SM0002: Empty module name @@ -870,16 +861,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) } // SM0044: Multiple IModuleOptions for same module - var optionsByModule = new Dictionary>(); - foreach (var opt in data.ModuleOptions) - { - if (!optionsByModule.TryGetValue(opt.ModuleName, out var list)) - { - list = new List(); - optionsByModule[opt.ModuleName] = list; - } - list.Add(opt); - } + var optionsByModule = ModuleOptionsRecord.GroupByModule(data.ModuleOptions); foreach (var kvp in optionsByModule) { @@ -896,28 +878,6 @@ public void Emit(SourceProductionContext context, DiscoveryData data) ); } } - - // SM0045: IModuleOptions class not associated with a known module - var knownModuleNames = new HashSet(); - foreach (var module in data.Modules) - { - knownModuleNames.Add(module.ModuleName); - } - - foreach (var opt in data.ModuleOptions) - { - if (!knownModuleNames.Contains(opt.ModuleName)) - { - context.ReportDiagnostic( - Diagnostic.Create( - ModuleOptionsOrphan, - Location.None, - Strip(opt.FullyQualifiedName), - opt.ModuleName - ) - ); - } - } } private static string Strip(string fqn) => TypeMappingHelpers.StripGlobalPrefix(fqn); diff --git a/framework/SimpleModule.Generator/Emitters/ModuleOptionsEmitter.cs b/framework/SimpleModule.Generator/Emitters/ModuleOptionsEmitter.cs index e6af5707..91e861e8 100644 --- a/framework/SimpleModule.Generator/Emitters/ModuleOptionsEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/ModuleOptionsEmitter.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; -using System.Linq; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; @@ -15,17 +13,7 @@ internal sealed class ModuleOptionsEmitter : IEmitter { public void Emit(SourceProductionContext context, DiscoveryData data) { - // Group by module to detect duplicates (diagnosed separately) - var optionsByModule = new Dictionary>(); - foreach (var opt in data.ModuleOptions) - { - if (!optionsByModule.TryGetValue(opt.ModuleName, out var list)) - { - list = new List(); - optionsByModule[opt.ModuleName] = list; - } - list.Add(opt); - } + var optionsByModule = ModuleOptionsRecord.GroupByModule(data.ModuleOptions); var sb = new StringBuilder(); sb.AppendLine("// "); diff --git a/tests/SimpleModule.Generator.Tests/ModuleOptionsEmitterTests.cs b/tests/SimpleModule.Generator.Tests/ModuleOptionsEmitterTests.cs index d784263f..4e2a5a33 100644 --- a/tests/SimpleModule.Generator.Tests/ModuleOptionsEmitterTests.cs +++ b/tests/SimpleModule.Generator.Tests/ModuleOptionsEmitterTests.cs @@ -74,10 +74,11 @@ public class ProductsModuleOptions : IModuleOptions } [Fact] - public void MultipleModules_WithOptions_GeneratesConfigureForEach() + public void MultipleModules_InSameAssembly_GeneratesConfigureForDiscoveredOptions() { - // In a single-assembly compilation, all options are discovered for all - // modules in that assembly. Both Configure methods are generated. + // In a single-assembly compilation, ScanModuleAssemblies deduplicates + // by assembly and discovers options for the first module in that assembly. + // In production, each module lives in a separate assembly. var source = """ using SimpleModule.Core; @@ -93,11 +94,6 @@ public class ProductsModuleOptions : IModuleOptions [Module("Orders")] public class OrdersModule : IModule { } - - public class OrdersModuleOptions : IModuleOptions - { - public int MaxItems { get; set; } = 50; - } } """; @@ -106,9 +102,9 @@ public class OrdersModuleOptions : IModuleOptions var generated = GetGeneratedSource(result, "ModuleOptionsExtensions.g.cs"); - // Both module Configure methods should exist + // Options class is discovered and a Configure method is generated generated.Should().Contain("ConfigureProducts("); - generated.Should().Contain("ConfigureOrders("); + generated.Should().Contain("ProductsModuleOptions"); } [Fact]