Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-SM0044)
- Framework contributor guidelines

## Key Constraints
Expand Down
26 changes: 26 additions & 0 deletions docs/CONSTITUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,31 @@ 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<T>
public class BrowseEndpoint(IOptions<ProductsModuleOptions> 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.
### What a Module Must Never Expose

- Entity classes
Expand Down Expand Up @@ -371,6 +396,7 @@ 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 |

### Dependencies

Expand Down
21 changes: 21 additions & 0 deletions framework/SimpleModule.Core/IModuleOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace SimpleModule.Core;

/// <summary>
/// Marker interface for module options classes. Implementations are auto-discovered
/// by the source generator, registered with <c>IOptions&lt;T&gt;</c>, and exposed as
/// typed <c>Configure{Module}()</c> methods on <c>SimpleModuleOptions</c>.
/// </summary>
/// <remarks>
/// Each module may define at most one options class implementing this interface.
/// The host application can then configure module behavior at startup:
/// <code>
/// builder.AddSimpleModule(o =&gt;
/// {
/// o.ConfigureProducts(p =&gt; p.MaxPageSize = 50);
/// });
/// </code>
/// Module code reads configured values via <c>IOptions&lt;TOptions&gt;</c> injection.
/// </remarks>
#pragma warning disable CA1040 // Avoid empty interfaces
public interface IModuleOptions;
#pragma warning restore CA1040
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ 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
27 changes: 27 additions & 0 deletions framework/SimpleModule.Generator/Discovery/DiscoveryData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ internal readonly record struct DiscoveryData(
ImmutableArray<PermissionClassRecord> PermissionClasses,
ImmutableArray<InterceptorInfoRecord> Interceptors,
ImmutableArray<VogenValueObjectRecord> VogenValueObjects,
ImmutableArray<ModuleOptionsRecord> ModuleOptions,
string HostAssemblyName
)
{
Expand All @@ -44,6 +45,7 @@ string HostAssemblyName
ImmutableArray<PermissionClassRecord>.Empty,
ImmutableArray<InterceptorInfoRecord>.Empty,
ImmutableArray<VogenValueObjectRecord>.Empty,
ImmutableArray<ModuleOptionsRecord>.Empty,
""
);

Expand All @@ -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;
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -312,6 +316,29 @@ public override int GetHashCode()
}
}

internal readonly record struct ModuleOptionsRecord(
string FullyQualifiedName,
string ModuleName
)
{
internal static Dictionary<string, List<ModuleOptionsRecord>> GroupByModule(
ImmutableArray<ModuleOptionsRecord> options
)
{
var result = new Dictionary<string, List<ModuleOptionsRecord>>();
foreach (var opt in options)
{
if (!result.TryGetValue(opt.ModuleName, out var list))
{
list = new List<ModuleOptionsRecord>();
result[opt.ModuleName] = list;
}
list.Add(opt);
}
return result;
}
}

internal readonly record struct VogenValueObjectRecord(
string TypeFqn,
string ConverterFqn,
Expand Down
84 changes: 84 additions & 0 deletions framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,40 @@ is not IAssemblySymbol assemblySymbol
}
);

// Step 3f: Find IModuleOptions implementors in module and contracts assemblies
var moduleOptionsList = new List<ModuleOptionsRecord>();
var moduleOptionsSymbol = compilation.GetTypeByMetadataName(
"SimpleModule.Core.IModuleOptions"
);
if (moduleOptionsSymbol is not null)
{
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)
{
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<ModuleDependencyRecord>();
var illegalReferences = new List<IllegalModuleReferenceRecord>();
Expand Down Expand Up @@ -537,6 +571,7 @@ is not IAssemblySymbol assemblySymbol
))
.ToImmutableArray(),
vogenValueObjects.ToImmutableArray(),
moduleOptionsList.ToImmutableArray(),
hostAssemblyName
);
}
Expand Down Expand Up @@ -1227,6 +1262,55 @@ m is IFieldSymbol field
}
}

private static void FindModuleOptionsClasses(
INamespaceSymbol namespaceSymbol,
INamedTypeSymbol moduleOptionsSymbol,
string moduleName,
List<ModuleOptionsRecord> results
)
{
FindConcreteClassesImplementing(
namespaceSymbol,
moduleOptionsSymbol,
typeSymbol =>
results.Add(
new ModuleOptionsRecord(
typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
moduleName
)
)
);
}

/// <summary>
/// Recursively walks namespaces and invokes <paramref name="onMatch"/> for each
/// concrete (non-abstract, non-static) class that implements the given interface.
/// </summary>
private static void FindConcreteClassesImplementing(
INamespaceSymbol namespaceSymbol,
INamedTypeSymbol interfaceSymbol,
Action<INamedTypeSymbol> onMatch
)
{
foreach (var member in namespaceSymbol.GetMembers())
{
if (member is INamespaceSymbol childNs)
{
FindConcreteClassesImplementing(childNs, interfaceSymbol, onMatch);
}
else if (
member is INamedTypeSymbol typeSymbol
&& typeSymbol.TypeKind == TypeKind.Class
&& !typeSymbol.IsAbstract
&& !typeSymbol.IsStatic
&& ImplementsInterface(typeSymbol, interfaceSymbol)
)
{
onMatch(typeSymbol);
}
}
}

private static void FindInterceptorTypes(
INamespaceSymbol namespaceSymbol,
INamedTypeSymbol saveChangesInterceptorSymbol,
Expand Down
28 changes: 28 additions & 0 deletions framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,15 @@ 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
);

public void Emit(SourceProductionContext context, DiscoveryData data)
{
// SM0002: Empty module name
Expand Down Expand Up @@ -850,6 +859,25 @@ public void Emit(SourceProductionContext context, DiscoveryData data)
}
}
}

// SM0044: Multiple IModuleOptions for same module
var optionsByModule = ModuleOptionsRecord.GroupByModule(data.ModuleOptions);

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)
)
);
}
}
}

private static string Strip(string fqn) => TypeMappingHelpers.StripGlobalPrefix(fqn);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,12 @@ public void Emit(SourceProductionContext context, DiscoveryData data)
sb.AppendLine(" this WebApplicationBuilder builder,");
sb.AppendLine(" System.Action<SimpleModuleOptions>? 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");
Expand All @@ -60,6 +62,9 @@ public void Emit(SourceProductionContext context, DiscoveryData data)
" builder.Services.AddModuleDbContext<HostDbContext>(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(
Expand Down
83 changes: 83 additions & 0 deletions framework/SimpleModule.Generator/Emitters/ModuleOptionsEmitter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace SimpleModule.Generator;

/// <summary>
/// Emits extension methods on SimpleModuleOptions for each discovered IModuleOptions class
/// (e.g., ConfigureProducts()) and a RegisterModuleOptionsDefaults() helper that registers
/// IOptions&lt;T&gt; for all discovered options types.
/// </summary>
internal sealed class ModuleOptionsEmitter : IEmitter
{
public void Emit(SourceProductionContext context, DiscoveryData data)
{
var optionsByModule = ModuleOptionsRecord.GroupByModule(data.ModuleOptions);

var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
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(
"/// <summary>"
);
sb.AppendLine(
"/// Generated extension methods for configuring module options."
);
sb.AppendLine("/// </summary>");
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($" /// <summary>");
sb.AppendLine($" /// Configures options for the {moduleName} module.");
sb.AppendLine($" /// </summary>");
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(" /// <summary>");
sb.AppendLine(
" /// Registers IOptions&lt;T&gt; defaults for all discovered module options."
);
sb.AppendLine(" /// </summary>");
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)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public class ModuleDiscovererGenerator : IIncrementalGenerator
new JsonResolverEmitter(),
new TypeScriptDefinitionsEmitter(),
new HostingExtensionsEmitter(),
new ModuleOptionsEmitter(),
new HostDbContextEmitter(),
new ValueConverterConventionsEmitter(),
new DbContextRegistryEmitter(),
Expand Down
Loading