diff --git a/.gitignore b/.gitignore
index 399d592b..663bb008 100644
--- a/.gitignore
+++ b/.gitignore
@@ -440,3 +440,6 @@ website/dist/
# Local file storage (runtime-generated by dev/e2e runs)
template/SimpleModule.Host/storage/
+
+# Temporary refactor baseline — not committed
+baseline/
diff --git a/docs/superpowers/plans/2026-04-15-generator-split-and-perf.md b/docs/superpowers/plans/2026-04-15-generator-split-and-perf.md
new file mode 100644
index 00000000..738ce631
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-15-generator-split-and-perf.md
@@ -0,0 +1,2775 @@
+# Source Generator Split & Perf Wins Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Split `framework/SimpleModule.Generator/` files over 300 lines into cohesive sub-300-line files, and land seven safe performance improvements without changing generated output.
+
+**Architecture:** `ModuleDiscovererGenerator` keeps the same incremental pipeline. `SymbolDiscovery.Extract` stays the orchestrator but delegates to per-responsibility finder classes (`Discovery/Finders/*`). Equatable records move out of one giant `DiscoveryData.cs` into grouped `Records/*`. `DiagnosticEmitter` becomes a router; 38 descriptors move to `DiagnosticDescriptors`, and per-concern checker classes hold the logic. A new `CoreSymbols` record resolves `GetTypeByMetadataName` once per Extract and threads through every finder.
+
+**Tech Stack:** .NET source generators, Roslyn (`Microsoft.CodeAnalysis`), `IIncrementalGenerator`, netstandard2.0, xUnit.v3 + FluentAssertions for tests.
+
+**Spec:** [docs/superpowers/specs/2026-04-15-generator-split-and-perf-design.md](../specs/2026-04-15-generator-split-and-perf-design.md)
+
+---
+
+## File map (end state)
+
+### New files
+
+```
+framework/SimpleModule.Generator/
+ Discovery/
+ AssemblyConventions.cs # relocated from DiagnosticEmitter.cs
+ CoreSymbols.cs # NEW
+ SymbolHelpers.cs # extracted cross-cutting helpers
+ Records/
+ ModuleRecords.cs # split out of DiscoveryData.cs
+ DataRecords.cs # split out of DiscoveryData.cs
+ Finders/
+ ModuleFinder.cs
+ EndpointFinder.cs
+ DtoFinder.cs
+ DbContextFinder.cs
+ ContractFinder.cs
+ PermissionFeatureFinder.cs
+ InterceptorFinder.cs
+ AgentFinder.cs
+ VogenFinder.cs
+ Emitters/
+ Diagnostics/
+ DiagnosticDescriptors.cs # 38 descriptors
+ ModuleChecks.cs
+ DbContextChecks.cs
+ ContractAndDtoChecks.cs
+ PermissionFeatureChecks.cs
+ EndpointChecks.cs
+ DependencyChecks.cs
+```
+
+### Trimmed files
+
+| File | Before | After target |
+|---|---|---|
+| `Discovery/SymbolDiscovery.cs` | 2068 | ≤ 200 (orchestrator) |
+| `Discovery/DiscoveryData.cs` | 640 | ≤ 220 (top record + hash + SourceLocationRecord) |
+| `Emitters/DiagnosticEmitter.cs` | 1294 | ≤ 80 (orchestrator) |
+
+### New tests
+
+```
+tests/SimpleModule.Generator.Tests/
+ IncrementalCachingTests.cs
+ DiagnosticCatalogTests.cs
+```
+
+---
+
+## Task 1: Capture baseline
+
+**Files:**
+- Create: `baseline/generator-output/` (temp, not committed)
+- Create: `baseline/diagnostics.txt` (temp, not committed)
+
+- [ ] **Step 1: Build the host to populate obj/.../generated/**
+
+Run: `dotnet build template/SimpleModule.Host -c Debug`
+Expected: Build succeeds.
+
+- [ ] **Step 2: Snapshot the generated source files**
+
+Run:
+```bash
+GEN_DIR=$(find template/SimpleModule.Host/obj/Debug -type d -name "SimpleModule.Generator" | head -1)
+mkdir -p baseline/generator-output
+cp "$GEN_DIR"/../../*.cs baseline/generator-output/ 2>/dev/null || true
+cp -r "$GEN_DIR"/* baseline/generator-output/
+ls baseline/generator-output/ | wc -l
+```
+Expected: Non-zero file count (should be ~20 generated files).
+
+- [ ] **Step 3: Snapshot the diagnostic descriptor set**
+
+Run:
+```bash
+grep -E "DiagnosticDescriptor [A-Za-z_]+ = new" framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs \
+ | sed -E 's/.*DiagnosticDescriptor ([A-Za-z_]+) = new.*/\1/' \
+ | sort > baseline/diagnostics.txt
+wc -l baseline/diagnostics.txt
+```
+Expected: `38 baseline/diagnostics.txt`
+
+- [ ] **Step 4: Run the full generator test suite, save as baseline**
+
+Run: `dotnet test tests/SimpleModule.Generator.Tests --logger "console;verbosity=minimal"`
+Expected: All tests pass.
+
+- [ ] **Step 5: Add baseline/ to .gitignore (don't commit the snapshot)**
+
+Read `.gitignore` to see its shape, then append:
+```
+# Temporary refactor baseline — not committed
+baseline/
+```
+
+- [ ] **Step 6: Commit the gitignore change**
+
+```bash
+git add .gitignore
+git commit -m "chore(generator): ignore baseline snapshot dir used during refactor"
+```
+
+---
+
+## Task 2: Relocate `AssemblyConventions` to Discovery namespace
+
+`AssemblyConventions` is currently inside `Emitters/DiagnosticEmitter.cs` (lines 10-37) but will be used by both discovery and diagnostics. Move it to its own file under `Discovery/` so it's a neutral dependency.
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Discovery/AssemblyConventions.cs`
+- Modify: `framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs` (remove lines 10-37)
+
+- [ ] **Step 1: Create the new file with the exact content of the existing class**
+
+Write `framework/SimpleModule.Generator/Discovery/AssemblyConventions.cs`:
+```csharp
+using System;
+
+namespace SimpleModule.Generator;
+
+///
+/// Naming conventions for SimpleModule assemblies. Centralised so the same
+/// string literals don't drift between discovery code and diagnostic emission.
+///
+internal static class AssemblyConventions
+{
+ internal const string FrameworkPrefix = "SimpleModule.";
+ internal const string ContractsSuffix = ".Contracts";
+ internal const string ModuleSuffix = ".Module";
+
+ ///
+ /// Derives the `.Contracts` sibling assembly name for a SimpleModule
+ /// implementation assembly. Strips a trailing .Module suffix first
+ /// so SimpleModule.Agents.Module maps to
+ /// SimpleModule.Agents.Contracts instead of
+ /// SimpleModule.Agents.Module.Contracts.
+ ///
+ internal static string GetExpectedContractsAssemblyName(string implementationAssemblyName)
+ {
+ var baseName = implementationAssemblyName.EndsWith(ModuleSuffix, StringComparison.Ordinal)
+ ? implementationAssemblyName.Substring(
+ 0,
+ implementationAssemblyName.Length - ModuleSuffix.Length
+ )
+ : implementationAssemblyName;
+ return baseName + ContractsSuffix;
+ }
+}
+```
+
+- [ ] **Step 2: Remove the duplicated class from DiagnosticEmitter.cs**
+
+In `framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs`, delete the `AssemblyConventions` class (the one starting with `/// ` at line 10 through its closing `}` — should be ~27 lines removed). Keep `using System;` at the top (still needed elsewhere in the file).
+
+- [ ] **Step 3: Build**
+
+Run: `dotnet build framework/SimpleModule.Generator -c Debug`
+Expected: Build succeeds with zero new warnings.
+
+- [ ] **Step 4: Run generator tests**
+
+Run: `dotnet test tests/SimpleModule.Generator.Tests`
+Expected: All tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/AssemblyConventions.cs \
+ framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
+git commit -m "refactor(generator): move AssemblyConventions to Discovery namespace"
+```
+
+---
+
+## Task 3: Introduce `CoreSymbols` record
+
+Resolves every `compilation.GetTypeByMetadataName` call once per Extract. Threaded through finders in later tasks.
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Discovery/CoreSymbols.cs`
+
+- [ ] **Step 1: Write the record**
+
+Write `framework/SimpleModule.Generator/Discovery/CoreSymbols.cs`:
+```csharp
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+///
+/// Pre-resolved Roslyn type symbols needed during discovery. Resolving each
+/// symbol once via at the top
+/// of SymbolDiscovery.Extract is dramatically cheaper than scattering
+/// calls across finder methods — every call force-resolves the namespace
+/// chain, so caching them saves ~15 lookups per Extract invocation.
+///
+internal readonly record struct CoreSymbols(
+ INamedTypeSymbol ModuleAttribute,
+ INamedTypeSymbol? DtoAttribute,
+ INamedTypeSymbol? EndpointInterface,
+ INamedTypeSymbol? ViewEndpointInterface,
+ INamedTypeSymbol? AgentDefinition,
+ INamedTypeSymbol? AgentToolProvider,
+ INamedTypeSymbol? KnowledgeSource,
+ INamedTypeSymbol? ModuleServices,
+ INamedTypeSymbol? ModuleMenu,
+ INamedTypeSymbol? ModuleMiddleware,
+ INamedTypeSymbol? ModuleSettings,
+ INamedTypeSymbol? NoDtoAttribute,
+ INamedTypeSymbol? EventInterface,
+ INamedTypeSymbol? ModulePermissions,
+ INamedTypeSymbol? ModuleFeatures,
+ INamedTypeSymbol? SaveChangesInterceptor,
+ INamedTypeSymbol? ModuleOptions,
+ bool HasAgentsAssembly
+)
+{
+ ///
+ /// Resolves all framework type symbols from the current compilation.
+ /// Returns null if the ModuleAttribute itself isn't resolvable —
+ /// discovery cannot proceed without it.
+ ///
+ internal static CoreSymbols? TryResolve(Compilation compilation)
+ {
+ var moduleAttribute = compilation.GetTypeByMetadataName("SimpleModule.Core.ModuleAttribute");
+ if (moduleAttribute is null)
+ return null;
+
+ return new CoreSymbols(
+ ModuleAttribute: moduleAttribute,
+ DtoAttribute: compilation.GetTypeByMetadataName("SimpleModule.Core.DtoAttribute"),
+ EndpointInterface: compilation.GetTypeByMetadataName("SimpleModule.Core.IEndpoint"),
+ ViewEndpointInterface: compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.IViewEndpoint"
+ ),
+ AgentDefinition: compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.Agents.IAgentDefinition"
+ ),
+ AgentToolProvider: compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.Agents.IAgentToolProvider"
+ ),
+ KnowledgeSource: compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.Rag.IKnowledgeSource"
+ ),
+ ModuleServices: compilation.GetTypeByMetadataName("SimpleModule.Core.IModuleServices"),
+ ModuleMenu: compilation.GetTypeByMetadataName("SimpleModule.Core.IModuleMenu"),
+ ModuleMiddleware: compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.IModuleMiddleware"
+ ),
+ ModuleSettings: compilation.GetTypeByMetadataName("SimpleModule.Core.IModuleSettings"),
+ NoDtoAttribute: compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.NoDtoGenerationAttribute"
+ ),
+ EventInterface: compilation.GetTypeByMetadataName("SimpleModule.Core.Events.IEvent"),
+ ModulePermissions: compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.Authorization.IModulePermissions"
+ ),
+ ModuleFeatures: compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.FeatureFlags.IModuleFeatures"
+ ),
+ SaveChangesInterceptor: compilation.GetTypeByMetadataName(
+ "Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor"
+ ),
+ ModuleOptions: compilation.GetTypeByMetadataName("SimpleModule.Core.IModuleOptions"),
+ HasAgentsAssembly:
+ compilation.GetTypeByMetadataName("SimpleModule.Agents.SimpleModuleAgentExtensions")
+ is not null
+ );
+ }
+}
+```
+
+- [ ] **Step 2: Build**
+
+Run: `dotnet build framework/SimpleModule.Generator`
+Expected: Build succeeds. (`CoreSymbols` is not referenced yet — that's fine.)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/CoreSymbols.cs
+git commit -m "feat(generator): add CoreSymbols record for one-shot type resolution"
+```
+
+---
+
+## Task 4: Use `CoreSymbols` in `SymbolDiscovery.Extract`
+
+Replace the scattered `compilation.GetTypeByMetadataName` calls at the top of `Extract` with a single `CoreSymbols.TryResolve` call, then pass the record through to existing finders. Keep finder signatures mostly the same — we'll clean them up in the split phase.
+
+**Files:**
+- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs` (Extract method)
+
+- [ ] **Step 1: Replace the top of `Extract` with a `CoreSymbols` bootstrap**
+
+In `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`, replace lines 35-81 (the section starting with `internal static DiscoveryData Extract(` through the end of the per-symbol resolutions) so the new top of the method reads:
+
+```csharp
+ internal static DiscoveryData Extract(
+ Compilation compilation,
+ CancellationToken cancellationToken
+ )
+ {
+ var hostAssemblyName = compilation.Assembly.Name;
+
+ var symbols = CoreSymbols.TryResolve(compilation);
+ if (symbols is null)
+ return DiscoveryData.Empty;
+ var s = symbols.Value;
+```
+
+Then update every downstream usage in the method body:
+- `moduleAttributeSymbol` → `s.ModuleAttribute`
+- `dtoAttributeSymbol` → `s.DtoAttribute`
+- `endpointInterfaceSymbol` → `s.EndpointInterface`
+- `viewEndpointInterfaceSymbol` → `s.ViewEndpointInterface`
+- `agentDefinitionSymbol` → `s.AgentDefinition`
+- `agentToolProviderSymbol` → `s.AgentToolProvider`
+- `knowledgeSourceSymbol` → `s.KnowledgeSource`
+- `moduleServicesSymbol` → `s.ModuleServices`
+- `moduleMenuSymbol` → `s.ModuleMenu`
+- `moduleMiddlewareSymbol` → `s.ModuleMiddleware`
+- `moduleSettingsSymbol` → `s.ModuleSettings`
+- `noDtoAttrSymbol` → `s.NoDtoAttribute`
+- `eventInterfaceSymbol` → `s.EventInterface`
+- `modulePermissionsSymbol` → `s.ModulePermissions`
+- `moduleFeaturesSymbol` → `s.ModuleFeatures`
+- `saveChangesInterceptorSymbol` → `s.SaveChangesInterceptor`
+- `moduleOptionsSymbol` → `s.ModuleOptions`
+
+Delete the local variable declarations that resolved each symbol (the original block was 17 `GetTypeByMetadataName` calls; they're all replaced by `CoreSymbols.TryResolve`).
+
+Also replace the `DiscoveryData` constructor call near the end where `HasAgentsAssembly` was computed inline — use `s.HasAgentsAssembly` instead.
+
+- [ ] **Step 2: Build**
+
+Run: `dotnet build framework/SimpleModule.Generator`
+Expected: Build succeeds. If any symbol variable was missed, the compiler will flag it.
+
+- [ ] **Step 3: Run generator tests**
+
+Run: `dotnet test tests/SimpleModule.Generator.Tests`
+Expected: All tests pass.
+
+- [ ] **Step 4: Diff generated output against baseline**
+
+Run:
+```bash
+dotnet build template/SimpleModule.Host -c Debug
+GEN_DIR=$(find template/SimpleModule.Host/obj/Debug -type d -name "SimpleModule.Generator" | head -1)
+diff -r baseline/generator-output "$GEN_DIR" | head -50
+```
+Expected: No output (identical).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+git commit -m "perf(generator): resolve core symbols once via CoreSymbols record"
+```
+
+---
+
+## Task 5: Split `DiscoveryData.cs` — create `Records/ModuleRecords.cs`
+
+Move module-related equatable records out so `DiscoveryData.cs` stays focused.
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs`
+- Modify: `framework/SimpleModule.Generator/Discovery/DiscoveryData.cs`
+
+- [ ] **Step 1: Create the new file with relocated records**
+
+Write `framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs` containing (copy from `DiscoveryData.cs` lines 135-334 verbatim — preserve every `Equals`/`GetHashCode` override):
+- `ModuleInfoRecord` (with custom Equals/GetHashCode)
+- `EndpointInfoRecord` (with custom Equals/GetHashCode)
+- `ViewInfoRecord`
+- `ModuleDependencyRecord`
+- `IllegalModuleReferenceRecord`
+
+File header:
+```csharp
+using System.Collections.Immutable;
+using System.Linq;
+
+namespace SimpleModule.Generator;
+```
+
+- [ ] **Step 2: Delete the moved records from DiscoveryData.cs**
+
+In `framework/SimpleModule.Generator/Discovery/DiscoveryData.cs`, delete lines 135-334 (the 5 records just copied). Leave the top-level `DiscoveryData`, `HashHelper`, `SourceLocationRecord`, and the other records that will move in the next task.
+
+- [ ] **Step 3: Build**
+
+Run: `dotnet build framework/SimpleModule.Generator`
+Expected: Build succeeds.
+
+- [ ] **Step 4: Run generator tests**
+
+Run: `dotnet test tests/SimpleModule.Generator.Tests`
+Expected: All tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs \
+ framework/SimpleModule.Generator/Discovery/DiscoveryData.cs
+git commit -m "refactor(generator): split ModuleRecords out of DiscoveryData"
+```
+
+---
+
+## Task 6: Split `DiscoveryData.cs` — create `Records/DataRecords.cs`
+
+Move the remaining equatable records and the mutable working types. `DiscoveryData.cs` ends up holding only the top-level `DiscoveryData`, `HashHelper`, and `SourceLocationRecord`.
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Discovery/Records/DataRecords.cs`
+- Modify: `framework/SimpleModule.Generator/Discovery/DiscoveryData.cs`
+
+- [ ] **Step 1: Create the new file**
+
+Write `framework/SimpleModule.Generator/Discovery/Records/DataRecords.cs` containing the following records (copy verbatim from the current `DiscoveryData.cs`):
+
+**Equatable records** (move from current lines 236-482):
+- `DtoTypeInfoRecord` + `DtoPropertyInfoRecord`
+- `DbContextInfoRecord` + `DbSetInfoRecord`
+- `EntityConfigInfoRecord`
+- `ContractInterfaceInfoRecord`
+- `ContractImplementationRecord`
+- `PermissionClassRecord` + `PermissionFieldRecord`
+- `FeatureClassRecord` + `FeatureFieldRecord`
+- `InterceptorInfoRecord`
+- `ModuleOptionsRecord` (including its `GroupByModule` helper)
+- `VogenValueObjectRecord`
+- `AgentDefinitionRecord`, `AgentToolProviderRecord`, `KnowledgeSourceRecord`
+
+**Mutable working types** (move from current lines 488-638):
+- `ModuleInfo`, `EndpointInfo`, `ViewInfo`
+- `DtoTypeInfo`, `DtoPropertyInfo`
+- `DbContextInfo`, `DbSetInfo`, `EntityConfigInfo`
+- `ContractImplementationInfo`
+- `PermissionClassInfo`, `PermissionFieldInfo`
+- `FeatureClassInfo`, `FeatureFieldInfo`
+- `InterceptorInfo`, `DiscoveredTypeInfo`
+
+File header:
+```csharp
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+
+namespace SimpleModule.Generator;
+```
+
+- [ ] **Step 2: Trim DiscoveryData.cs to its core**
+
+In `framework/SimpleModule.Generator/Discovery/DiscoveryData.cs`, delete everything from `internal readonly record struct DtoTypeInfoRecord` through the final `#endregion` — leaving only `HashHelper`, `SourceLocationRecord`, and the top-level `DiscoveryData` record (including its `#region` markers if you want to keep them). The file should end up around 200 lines.
+
+- [ ] **Step 3: Verify file size**
+
+Run: `wc -l framework/SimpleModule.Generator/Discovery/DiscoveryData.cs`
+Expected: ≤ 220.
+
+- [ ] **Step 4: Build**
+
+Run: `dotnet build framework/SimpleModule.Generator`
+Expected: Build succeeds.
+
+- [ ] **Step 5: Run generator tests**
+
+Run: `dotnet test tests/SimpleModule.Generator.Tests`
+Expected: All tests pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/Records/DataRecords.cs \
+ framework/SimpleModule.Generator/Discovery/DiscoveryData.cs
+git commit -m "refactor(generator): split DataRecords out of DiscoveryData, trim top file to 200 lines"
+```
+
+---
+
+## Task 7: Extract `SymbolHelpers.cs`
+
+Pull cross-cutting helper methods from `SymbolDiscovery.cs` into a shared helper class before the finder splits — every finder depends on some of these.
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Discovery/SymbolHelpers.cs`
+- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`
+
+- [ ] **Step 1: Create the new file**
+
+Write `framework/SimpleModule.Generator/Discovery/SymbolHelpers.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class SymbolHelpers
+{
+ ///
+ /// Extracts a serializable source location from a symbol, if available.
+ /// Returns null for symbols only available in metadata (compiled DLLs).
+ ///
+ internal static SourceLocationRecord? GetSourceLocation(ISymbol symbol)
+ {
+ foreach (var loc in symbol.Locations)
+ {
+ if (loc.IsInSource)
+ {
+ var span = loc.GetLineSpan();
+ return new SourceLocationRecord(
+ span.Path,
+ span.StartLinePosition.Line,
+ span.StartLinePosition.Character,
+ span.EndLinePosition.Line,
+ span.EndLinePosition.Character
+ );
+ }
+ }
+ return null;
+ }
+
+ internal static bool ImplementsInterface(
+ INamedTypeSymbol typeSymbol,
+ INamedTypeSymbol interfaceSymbol
+ )
+ {
+ foreach (var iface in typeSymbol.AllInterfaces)
+ {
+ if (SymbolEqualityComparer.Default.Equals(iface, interfaceSymbol))
+ return true;
+ }
+ return false;
+ }
+
+ internal static bool InheritsFrom(INamedTypeSymbol typeSymbol, INamedTypeSymbol baseType)
+ {
+ var current = typeSymbol.BaseType;
+ while (current is not null)
+ {
+ if (SymbolEqualityComparer.Default.Equals(current, baseType))
+ return true;
+ current = current.BaseType;
+ }
+ return false;
+ }
+
+ internal static bool DeclaresMethod(INamedTypeSymbol typeSymbol, string methodName)
+ {
+ foreach (var member in typeSymbol.GetMembers(methodName))
+ {
+ if (member is IMethodSymbol method)
+ {
+ if (method.DeclaringSyntaxReferences.Length > 0)
+ return true;
+ if (
+ !method.IsImplicitlyDeclared
+ && method.Locations.Any(static l => l.IsInMetadata)
+ )
+ return true;
+ }
+ }
+ return false;
+ }
+
+ internal static void ScanModuleAssemblies(
+ List modules,
+ Dictionary moduleSymbols,
+ Action action
+ )
+ {
+ var scanned = new HashSet(SymbolEqualityComparer.Default);
+ foreach (var module in modules)
+ {
+ if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol))
+ continue;
+
+ if (scanned.Add(typeSymbol.ContainingAssembly))
+ action(typeSymbol.ContainingAssembly, module);
+ }
+ }
+
+ internal static string FindClosestModuleName(string typeFqn, List modules)
+ {
+ // Match by longest shared namespace prefix between the type and each module class.
+ var bestMatch = "";
+ var bestLength = -1;
+ foreach (var module in modules)
+ {
+ var moduleFqn = TypeMappingHelpers.StripGlobalPrefix(module.FullyQualifiedName);
+ var moduleNs = TypeMappingHelpers.ExtractNamespace(moduleFqn);
+
+ if (
+ typeFqn.StartsWith(moduleNs, StringComparison.Ordinal)
+ && moduleNs.Length > bestLength
+ )
+ {
+ bestLength = moduleNs.Length;
+ bestMatch = module.ModuleName;
+ }
+ }
+
+ return bestMatch.Length > 0 ? bestMatch : modules[0].ModuleName;
+ }
+
+ ///
+ /// Recursively walks namespaces and invokes for each
+ /// concrete (non-abstract, non-static) class that implements the given interface.
+ ///
+ internal static void FindConcreteClassesImplementing(
+ INamespaceSymbol namespaceSymbol,
+ INamedTypeSymbol interfaceSymbol,
+ Action 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);
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Delete the moved helpers from SymbolDiscovery.cs**
+
+In `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`, delete these method definitions:
+- `GetSourceLocation` (lines ~16-33)
+- `ImplementsInterface` (lines ~1075-1086)
+- `ScanModuleAssemblies` (lines ~1088-1103)
+- `DeclaresMethod` (lines ~1105-1125)
+- `InheritsFrom` (lines ~1199-1209)
+- `FindClosestModuleName` (lines ~1269-1290)
+- `FindConcreteClassesImplementing` (lines ~1691-1714)
+
+- [ ] **Step 3: Update call sites in `Extract` and remaining finders**
+
+In the same file, prefix every call to the moved helpers with `SymbolHelpers.`:
+- `GetSourceLocation(...)` → `SymbolHelpers.GetSourceLocation(...)`
+- `ImplementsInterface(...)` → `SymbolHelpers.ImplementsInterface(...)`
+- `ScanModuleAssemblies(...)` → `SymbolHelpers.ScanModuleAssemblies(...)`
+- `DeclaresMethod(...)` → `SymbolHelpers.DeclaresMethod(...)`
+- `InheritsFrom(...)` → `SymbolHelpers.InheritsFrom(...)`
+- `FindClosestModuleName(...)` → `SymbolHelpers.FindClosestModuleName(...)`
+- `FindConcreteClassesImplementing(...)` → `SymbolHelpers.FindConcreteClassesImplementing(...)`
+
+- [ ] **Step 4: Build**
+
+Run: `dotnet build framework/SimpleModule.Generator`
+Expected: Build succeeds with zero new warnings.
+
+- [ ] **Step 5: Run generator tests**
+
+Run: `dotnet test tests/SimpleModule.Generator.Tests`
+Expected: All tests pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/SymbolHelpers.cs \
+ framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+git commit -m "refactor(generator): extract SymbolHelpers from SymbolDiscovery"
+```
+
+---
+
+## Task 8: Extract `Finders/ModuleFinder.cs`
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Discovery/Finders/ModuleFinder.cs`
+- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`
+
+- [ ] **Step 1: Create the finder**
+
+Write `framework/SimpleModule.Generator/Discovery/Finders/ModuleFinder.cs`:
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class ModuleFinder
+{
+ internal static void FindModuleTypes(
+ INamespaceSymbol namespaceSymbol,
+ CoreSymbols symbols,
+ List modules,
+ CancellationToken cancellationToken
+ )
+ {
+ foreach (var member in namespaceSymbol.GetMembers())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (member is INamespaceSymbol childNamespace)
+ {
+ FindModuleTypes(childNamespace, symbols, modules, cancellationToken);
+ }
+ else if (member is INamedTypeSymbol typeSymbol)
+ {
+ foreach (var attr in typeSymbol.GetAttributes())
+ {
+ if (
+ SymbolEqualityComparer.Default.Equals(
+ attr.AttributeClass,
+ symbols.ModuleAttribute
+ )
+ )
+ {
+ var moduleName =
+ attr.ConstructorArguments.Length > 0
+ ? attr.ConstructorArguments[0].Value as string ?? ""
+ : "";
+ var routePrefix = "";
+ var viewPrefix = "";
+ foreach (var namedArg in attr.NamedArguments)
+ {
+ if (
+ namedArg.Key == "RoutePrefix"
+ && namedArg.Value.Value is string prefix
+ )
+ {
+ routePrefix = prefix;
+ }
+ else if (
+ namedArg.Key == "ViewPrefix"
+ && namedArg.Value.Value is string vPrefix
+ )
+ {
+ viewPrefix = vPrefix;
+ }
+ }
+
+ modules.Add(
+ new ModuleInfo
+ {
+ FullyQualifiedName = typeSymbol.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ ),
+ ModuleName = moduleName,
+ HasConfigureServices =
+ SymbolHelpers.DeclaresMethod(typeSymbol, "ConfigureServices")
+ || (
+ symbols.ModuleServices is not null
+ && SymbolHelpers.ImplementsInterface(
+ typeSymbol,
+ symbols.ModuleServices
+ )
+ ),
+ HasConfigureEndpoints = SymbolHelpers.DeclaresMethod(
+ typeSymbol,
+ "ConfigureEndpoints"
+ ),
+ HasConfigureMenu =
+ SymbolHelpers.DeclaresMethod(typeSymbol, "ConfigureMenu")
+ || (
+ symbols.ModuleMenu is not null
+ && SymbolHelpers.ImplementsInterface(
+ typeSymbol,
+ symbols.ModuleMenu
+ )
+ ),
+ HasConfigureMiddleware =
+ SymbolHelpers.DeclaresMethod(typeSymbol, "ConfigureMiddleware")
+ || (
+ symbols.ModuleMiddleware is not null
+ && SymbolHelpers.ImplementsInterface(
+ typeSymbol,
+ symbols.ModuleMiddleware
+ )
+ ),
+ HasConfigurePermissions = SymbolHelpers.DeclaresMethod(
+ typeSymbol,
+ "ConfigurePermissions"
+ ),
+ HasConfigureSettings =
+ SymbolHelpers.DeclaresMethod(typeSymbol, "ConfigureSettings")
+ || (
+ symbols.ModuleSettings is not null
+ && SymbolHelpers.ImplementsInterface(
+ typeSymbol,
+ symbols.ModuleSettings
+ )
+ ),
+ HasConfigureFeatureFlags = SymbolHelpers.DeclaresMethod(
+ typeSymbol,
+ "ConfigureFeatureFlags"
+ ),
+ HasConfigureAgents = SymbolHelpers.DeclaresMethod(
+ typeSymbol,
+ "ConfigureAgents"
+ ),
+ HasConfigureRateLimits = SymbolHelpers.DeclaresMethod(
+ typeSymbol,
+ "ConfigureRateLimits"
+ ),
+ RoutePrefix = routePrefix,
+ ViewPrefix = viewPrefix,
+ AssemblyName = typeSymbol.ContainingAssembly.Name,
+ Location = SymbolHelpers.GetSourceLocation(typeSymbol),
+ }
+ );
+ break;
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Delete the old `FindModuleTypes` method from SymbolDiscovery.cs**
+
+Remove the `FindModuleTypes` method (the big one starting around line 827 in the pre-refactor file; use Grep to locate).
+
+- [ ] **Step 3: Update `Extract` call sites**
+
+In `SymbolDiscovery.Extract`, replace the two calls that previously passed `moduleAttributeSymbol, moduleServicesSymbol, moduleMenuSymbol, moduleMiddlewareSymbol, moduleSettingsSymbol, modules, cancellationToken` with:
+
+```csharp
+ModuleFinder.FindModuleTypes(
+ assemblySymbol.GlobalNamespace,
+ s,
+ modules,
+ cancellationToken
+);
+```
+
+and
+
+```csharp
+ModuleFinder.FindModuleTypes(
+ compilation.Assembly.GlobalNamespace,
+ s,
+ modules,
+ cancellationToken
+);
+```
+
+- [ ] **Step 4: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/Finders/ModuleFinder.cs \
+ framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+git commit -m "refactor(generator): extract ModuleFinder from SymbolDiscovery"
+```
+
+---
+
+## Task 9: Extract `Finders/EndpointFinder.cs`
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs`
+- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`
+
+- [ ] **Step 1: Create the finder**
+
+Write `framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs` containing (copy verbatim from the current `SymbolDiscovery.cs`, updating the 2 direct helper calls and the `endpointInterfaceSymbol`/`viewEndpointInterfaceSymbol` params to use `CoreSymbols`):
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class EndpointFinder
+{
+ internal static void FindEndpointTypes(
+ INamespaceSymbol namespaceSymbol,
+ CoreSymbols symbols,
+ List endpoints,
+ List views,
+ CancellationToken cancellationToken
+ )
+ {
+ if (symbols.EndpointInterface is null)
+ return;
+
+ FindEndpointTypesInternal(
+ namespaceSymbol,
+ symbols.EndpointInterface,
+ symbols.ViewEndpointInterface,
+ endpoints,
+ views,
+ cancellationToken
+ );
+ }
+
+ private static void FindEndpointTypesInternal(
+ INamespaceSymbol namespaceSymbol,
+ INamedTypeSymbol endpointInterfaceSymbol,
+ INamedTypeSymbol? viewEndpointInterfaceSymbol,
+ List endpoints,
+ List views,
+ CancellationToken cancellationToken
+ )
+ {
+ foreach (var member in namespaceSymbol.GetMembers())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (member is INamespaceSymbol childNamespace)
+ {
+ FindEndpointTypesInternal(
+ childNamespace,
+ endpointInterfaceSymbol,
+ viewEndpointInterfaceSymbol,
+ endpoints,
+ views,
+ cancellationToken
+ );
+ }
+ else if (member is INamedTypeSymbol typeSymbol)
+ {
+ if (!typeSymbol.IsAbstract && !typeSymbol.IsStatic)
+ {
+ var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ if (
+ viewEndpointInterfaceSymbol is not null
+ && SymbolHelpers.ImplementsInterface(typeSymbol, viewEndpointInterfaceSymbol)
+ )
+ {
+ var className = typeSymbol.Name;
+ if (className.EndsWith("Endpoint", StringComparison.Ordinal))
+ className = className.Substring(
+ 0,
+ className.Length - "Endpoint".Length
+ );
+ else if (className.EndsWith("View", StringComparison.Ordinal))
+ className = className.Substring(0, className.Length - "View".Length);
+
+ var viewInfo = new ViewInfo
+ {
+ FullyQualifiedName = fqn,
+ InferredClassName = className,
+ Location = SymbolHelpers.GetSourceLocation(typeSymbol),
+ };
+
+ var (viewRoute, _) = ReadRouteConstFields(typeSymbol);
+ viewInfo.RouteTemplate = viewRoute;
+ views.Add(viewInfo);
+ }
+ else if (SymbolHelpers.ImplementsInterface(typeSymbol, endpointInterfaceSymbol))
+ {
+ var info = new EndpointInfo { FullyQualifiedName = fqn };
+
+ foreach (var attr in typeSymbol.GetAttributes())
+ {
+ var attrName = attr.AttributeClass?.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ );
+
+ if (
+ attrName
+ == "global::SimpleModule.Core.Authorization.RequirePermissionAttribute"
+ )
+ {
+ if (attr.ConstructorArguments.Length > 0)
+ {
+ var arg = attr.ConstructorArguments[0];
+ if (arg.Kind == TypedConstantKind.Array)
+ {
+ foreach (var val in arg.Values)
+ {
+ if (val.Value is string s)
+ info.RequiredPermissions.Add(s);
+ }
+ }
+ else if (arg.Value is string single)
+ {
+ info.RequiredPermissions.Add(single);
+ }
+ }
+ }
+ else if (
+ attrName
+ == "global::Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute"
+ )
+ {
+ info.AllowAnonymous = true;
+ }
+ }
+
+ var (epRoute, epMethod) = ReadRouteConstFields(typeSymbol);
+ info.RouteTemplate = epRoute;
+ info.HttpMethod = epMethod;
+ endpoints.Add(info);
+ }
+ }
+ }
+ }
+ }
+
+ private static (string route, string method) ReadRouteConstFields(INamedTypeSymbol typeSymbol)
+ {
+ var route = "";
+ var method = "";
+ foreach (var m in typeSymbol.GetMembers())
+ {
+ if (m is IFieldSymbol { IsConst: true, ConstantValue: string value } field)
+ {
+ if (field.Name == "Route")
+ route = value;
+ else if (field.Name == "Method")
+ method = value;
+ }
+ }
+ return (route, method);
+ }
+}
+```
+
+- [ ] **Step 2: Delete the old `FindEndpointTypes` and `ReadRouteConstFields` from SymbolDiscovery.cs**
+
+Remove both methods.
+
+- [ ] **Step 3: Update the call site in `Extract`**
+
+In `SymbolDiscovery.Extract`, where `FindEndpointTypes` was called, replace with:
+```csharp
+EndpointFinder.FindEndpointTypes(
+ assembly.GlobalNamespace,
+ s,
+ rawEndpoints,
+ rawViews,
+ cancellationToken
+);
+```
+
+Also remove the `if (endpointInterfaceSymbol is not null)` outer guard — `EndpointFinder.FindEndpointTypes` handles it internally (per the Step 1 code above).
+
+- [ ] **Step 4: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs \
+ framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+git commit -m "refactor(generator): extract EndpointFinder from SymbolDiscovery"
+```
+
+---
+
+## Task 10: Extract `Finders/DtoFinder.cs`
+
+Move all DTO discovery — attribute-based, convention-based, property extraction, `[JsonIgnore]` check — into one file.
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs`
+- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`
+
+- [ ] **Step 1: Create the finder**
+
+Write `framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs`. Copy the following methods verbatim from the current `SymbolDiscovery.cs`:
+- `FindDtoTypes` (the attribute-based scanner)
+- `FindConventionDtoTypes`
+- `ExtractDtoProperties`
+- `HasJsonIgnoreAttribute`
+
+Make them `internal static` on a new class `DtoFinder`. Change any call to a helper moved to `SymbolHelpers` (e.g. `GetSourceLocation`) to use `SymbolHelpers.GetSourceLocation(...)`. Calls to `IsVogenValueObject` and `ResolveUnderlyingType` stay as-is for now (they'll move with VogenFinder in Task 14 — we'll update references then).
+
+Until VogenFinder exists, keep `IsVogenValueObject` and `ResolveUnderlyingType` still in `SymbolDiscovery.cs` as `internal static`. Prefix their call sites inside `DtoFinder` with `SymbolDiscovery.` temporarily:
+- `IsVogenValueObject(typeSymbol)` → `SymbolDiscovery.IsVogenValueObject(typeSymbol)`
+- `ResolveUnderlyingType(prop.Type)` → `SymbolDiscovery.ResolveUnderlyingType(prop.Type)`
+
+File header:
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class DtoFinder
+{
+ // Copy FindDtoTypes, FindConventionDtoTypes, ExtractDtoProperties, HasJsonIgnoreAttribute here
+ // (each as `internal static` or `private static` as appropriate)
+ // ...
+}
+```
+
+For `IsVogenValueObject` and `ResolveUnderlyingType` referenced in `FindConventionDtoTypes` and `ExtractDtoProperties`, make sure they stay accessible — in `SymbolDiscovery.cs` change their visibility from `private static` to `internal static`.
+
+- [ ] **Step 2: Delete the moved methods from SymbolDiscovery.cs**
+
+Remove `FindDtoTypes`, `FindConventionDtoTypes`, `ExtractDtoProperties`, `HasJsonIgnoreAttribute`.
+
+- [ ] **Step 3: Update `Extract` call sites**
+
+Change:
+- `FindDtoTypes(...)` → `DtoFinder.FindDtoTypes(...)` (two call sites — one per reference, one for the host assembly)
+- `FindConventionDtoTypes(...)` → `DtoFinder.FindConventionDtoTypes(...)`
+
+Also update the `FindDtoTypes` signature usage: the existing calls pass `dtoAttributeSymbol` — keep that parameter as-is in the finder (this finder predates CoreSymbols threading; we preserve current signature for byte-identical behavior).
+
+- [ ] **Step 4: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs \
+ framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+git commit -m "refactor(generator): extract DtoFinder from SymbolDiscovery"
+```
+
+---
+
+## Task 11: Extract `Finders/DbContextFinder.cs`
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs`
+- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`
+
+- [ ] **Step 1: Create the finder**
+
+Write `framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs` with:
+- `FindDbContextTypes` (copy verbatim)
+- `FindEntityConfigTypes` (copy verbatim)
+- `HasDbContextConstructorParam` (copy verbatim)
+
+All as `internal static` (the ones that are called from outside) / `private static` (for `HasDbContextConstructorParam` if only used internally — but it's actually called from `ContractFinder` too, so make it `internal static`).
+
+Replace internal helper calls with `SymbolHelpers.GetSourceLocation(...)`.
+
+- [ ] **Step 2: Delete the moved methods from SymbolDiscovery.cs**
+
+Remove `FindDbContextTypes`, `FindEntityConfigTypes`, `HasDbContextConstructorParam`.
+
+- [ ] **Step 3: Update call sites in `Extract`**
+
+- `FindDbContextTypes(...)` → `DbContextFinder.FindDbContextTypes(...)`
+- `FindEntityConfigTypes(...)` → `DbContextFinder.FindEntityConfigTypes(...)`
+- `HasDbContextConstructorParam(...)` (inside ContractFinder when we extract it) — we'll update that reference in Task 12.
+
+- [ ] **Step 4: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs \
+ framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+git commit -m "refactor(generator): extract DbContextFinder from SymbolDiscovery"
+```
+
+---
+
+## Task 12: Extract `Finders/ContractFinder.cs`
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs`
+- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`
+
+- [ ] **Step 1: Create the finder**
+
+Write `framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs` with:
+- `ScanContractInterfaces` (copy verbatim)
+- `FindContractImplementations` (copy verbatim)
+- `GetContractLifetime` (copy verbatim, as `internal static`)
+
+Replace:
+- `GetSourceLocation(...)` → `SymbolHelpers.GetSourceLocation(...)`
+- `HasDbContextConstructorParam(...)` → `DbContextFinder.HasDbContextConstructorParam(...)` (this is the reason we promoted it in Task 11)
+- `GetContractLifetime(...)` inside `FindContractImplementations` stays as-is (same file).
+
+- [ ] **Step 2: Delete the moved methods from SymbolDiscovery.cs**
+
+Remove `ScanContractInterfaces`, `FindContractImplementations`, `GetContractLifetime`.
+
+- [ ] **Step 3: Update call sites in `Extract`**
+
+- `ScanContractInterfaces(...)` → `ContractFinder.ScanContractInterfaces(...)`
+- `FindContractImplementations(...)` → `ContractFinder.FindContractImplementations(...)`
+
+- [ ] **Step 4: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs \
+ framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+git commit -m "refactor(generator): extract ContractFinder from SymbolDiscovery"
+```
+
+---
+
+## Task 13: Extract `Finders/PermissionFeatureFinder.cs`
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Discovery/Finders/PermissionFeatureFinder.cs`
+- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`
+
+- [ ] **Step 1: Create the finder**
+
+Write `framework/SimpleModule.Generator/Discovery/Finders/PermissionFeatureFinder.cs` with:
+- `FindPermissionClasses` (copy verbatim)
+- `FindFeatureClasses` (copy verbatim)
+- `FindModuleOptionsClasses` (copy verbatim)
+
+All as `internal static` class `PermissionFeatureFinder`. Replace helper calls with `SymbolHelpers.GetSourceLocation(...)` and `SymbolHelpers.ImplementsInterface(...)` and `SymbolHelpers.FindConcreteClassesImplementing(...)`.
+
+- [ ] **Step 2: Delete the moved methods from SymbolDiscovery.cs**
+
+Remove `FindPermissionClasses`, `FindFeatureClasses`, `FindModuleOptionsClasses`.
+
+- [ ] **Step 3: Update call sites in `Extract`**
+
+- `FindPermissionClasses(...)` → `PermissionFeatureFinder.FindPermissionClasses(...)`
+- `FindFeatureClasses(...)` → `PermissionFeatureFinder.FindFeatureClasses(...)`
+- `FindModuleOptionsClasses(...)` → `PermissionFeatureFinder.FindModuleOptionsClasses(...)`
+
+- [ ] **Step 4: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/Finders/PermissionFeatureFinder.cs \
+ framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+git commit -m "refactor(generator): extract PermissionFeatureFinder from SymbolDiscovery"
+```
+
+---
+
+## Task 14: Extract `Finders/VogenFinder.cs`, `Finders/InterceptorFinder.cs`, `Finders/AgentFinder.cs`
+
+Three small finders, bundled into one task since each is under 80 lines.
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Discovery/Finders/VogenFinder.cs`
+- Create: `framework/SimpleModule.Generator/Discovery/Finders/InterceptorFinder.cs`
+- Create: `framework/SimpleModule.Generator/Discovery/Finders/AgentFinder.cs`
+- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`
+
+- [ ] **Step 1: Create VogenFinder**
+
+Write `framework/SimpleModule.Generator/Discovery/Finders/VogenFinder.cs` with `FindVogenValueObjectsWithEfConverters`, `IsVogenValueObject`, `ResolveUnderlyingType` (copy verbatim). All as `internal static class VogenFinder`.
+
+- [ ] **Step 2: Create InterceptorFinder**
+
+Write `framework/SimpleModule.Generator/Discovery/Finders/InterceptorFinder.cs` with `FindInterceptorTypes` (copy verbatim). `internal static class InterceptorFinder`. Replace helper calls with `SymbolHelpers.GetSourceLocation(...)` and `SymbolHelpers.ImplementsInterface(...)`.
+
+- [ ] **Step 3: Create AgentFinder**
+
+Write `framework/SimpleModule.Generator/Discovery/Finders/AgentFinder.cs` with `FindImplementors` (copy verbatim). `internal static class AgentFinder`. Replace helper calls with `SymbolHelpers.ImplementsInterface(...)`.
+
+- [ ] **Step 4: Delete moved methods from SymbolDiscovery.cs**
+
+Remove:
+- `FindVogenValueObjectsWithEfConverters`
+- `IsVogenValueObject`
+- `ResolveUnderlyingType`
+- `FindInterceptorTypes`
+- `FindImplementors`
+
+- [ ] **Step 5: Update call sites in `Extract`**
+
+Replace:
+- `FindVogenValueObjectsWithEfConverters(...)` → `VogenFinder.FindVogenValueObjectsWithEfConverters(...)` (two call sites)
+- `FindInterceptorTypes(...)` → `InterceptorFinder.FindInterceptorTypes(...)`
+- `FindImplementors(...)` → `AgentFinder.FindImplementors(...)` (three call sites — agents, tool providers, knowledge sources)
+
+Also update the `DtoFinder` usage that referenced `SymbolDiscovery.IsVogenValueObject` and `SymbolDiscovery.ResolveUnderlyingType` — change to `VogenFinder.IsVogenValueObject` and `VogenFinder.ResolveUnderlyingType`.
+
+- [ ] **Step 6: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/Finders/VogenFinder.cs \
+ framework/SimpleModule.Generator/Discovery/Finders/InterceptorFinder.cs \
+ framework/SimpleModule.Generator/Discovery/Finders/AgentFinder.cs \
+ framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs \
+ framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+git commit -m "refactor(generator): extract Vogen, Interceptor, Agent finders"
+```
+
+---
+
+## Task 15: Verify `SymbolDiscovery.cs` is under 300 lines
+
+After the finder extractions, `SymbolDiscovery.cs` should contain only `Extract` (the orchestrator).
+
+**Files:**
+- Verify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`
+
+- [ ] **Step 1: Check size**
+
+Run: `wc -l framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`
+Expected: ≤ 300. If above, identify what's still there and split further.
+
+- [ ] **Step 2: Diff generated output against baseline**
+
+```bash
+dotnet build template/SimpleModule.Host -c Debug
+GEN_DIR=$(find template/SimpleModule.Host/obj/Debug -type d -name "SimpleModule.Generator" | head -1)
+diff -r baseline/generator-output "$GEN_DIR" | head -100
+```
+Expected: No output.
+
+- [ ] **Step 3: No commit needed** (verification only).
+
+---
+
+## Task 16: Extract `Emitters/Diagnostics/DiagnosticDescriptors.cs`
+
+Move all 38 `DiagnosticDescriptor` fields to a single file.
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.cs`
+- Modify: `framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs`
+
+- [ ] **Step 1: Create the descriptors file**
+
+Write `framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.cs`:
+
+```csharp
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class DiagnosticDescriptors
+{
+ // Move all 38 DiagnosticDescriptor fields here.
+ // Change each field's declaration from `private static readonly` or
+ // `internal static readonly` to `internal static readonly`.
+ // Keep the field names identical to the originals.
+ // Use the exact id/title/messageFormat/category/defaultSeverity/isEnabledByDefault
+ // values from DiagnosticEmitter.cs.
+}
+```
+
+Copy every `DiagnosticDescriptor` field definition from `DiagnosticEmitter.cs` (lines ~41-381 — use the grep list below for field names). Each must become `internal static readonly`:
+
+```
+DuplicateDbSetPropertyName, EmptyModuleName, MultipleIdentityDbContexts,
+IdentityDbContextBadTypeArgs, EntityConfigForMissingEntity, DuplicateEntityConfiguration,
+CircularModuleDependency, IllegalImplementationReference, ContractInterfaceTooLargeWarning,
+ContractInterfaceTooLargeError, MissingContractInterfaces, NoContractImplementation,
+MultipleContractImplementations, PermissionFieldNotConstString, ContractImplementationNotPublic,
+ContractImplementationIsAbstract, PermissionValueBadPattern, PermissionClassNotSealed,
+DuplicatePermissionValue, PermissionValueWrongPrefix, DtoTypeNoProperties,
+InfrastructureTypeInContracts, DuplicateViewPageName, InterceptorDependsOnDbContext,
+DuplicateModuleName, ViewPagePrefixMismatch, ViewEndpointWithoutViewPrefix,
+EmptyModuleWarning, MultipleModuleOptions, FeatureClassNotSealed,
+FeatureFieldNamingViolation, DuplicateFeatureName, FeatureFieldNotConstString,
+MultipleEndpointsPerFile, ModuleAssemblyNamingViolation, MissingContractsAssembly,
+MissingEndpointRouteConst, EntityNotInContractsAssembly
+```
+
+- [ ] **Step 2: Delete the descriptor fields from DiagnosticEmitter.cs**
+
+In `framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs`, delete every `DiagnosticDescriptor` field definition (lines ~41 through ~381 — keep the `public void Emit(...)` method and the `Strip`/`ExtractShortName` helpers).
+
+- [ ] **Step 3: Update references in `DiagnosticEmitter.Emit`**
+
+Prefix every bare descriptor reference in `Emit` (and helpers) with `DiagnosticDescriptors.`. Use a single replace-all:
+- `EmptyModuleName` → `DiagnosticDescriptors.EmptyModuleName`
+- `DuplicateModuleName` → `DiagnosticDescriptors.DuplicateModuleName`
+- …and so on for all 38.
+
+Tip: Do this with a loop in your shell to avoid typos:
+```bash
+for name in DuplicateDbSetPropertyName EmptyModuleName MultipleIdentityDbContexts \
+ IdentityDbContextBadTypeArgs EntityConfigForMissingEntity DuplicateEntityConfiguration \
+ CircularModuleDependency IllegalImplementationReference ContractInterfaceTooLargeWarning \
+ ContractInterfaceTooLargeError MissingContractInterfaces NoContractImplementation \
+ MultipleContractImplementations PermissionFieldNotConstString ContractImplementationNotPublic \
+ ContractImplementationIsAbstract PermissionValueBadPattern PermissionClassNotSealed \
+ DuplicatePermissionValue PermissionValueWrongPrefix DtoTypeNoProperties \
+ InfrastructureTypeInContracts DuplicateViewPageName InterceptorDependsOnDbContext \
+ ViewPagePrefixMismatch ViewEndpointWithoutViewPrefix EmptyModuleWarning \
+ MultipleModuleOptions FeatureClassNotSealed FeatureFieldNamingViolation \
+ DuplicateFeatureName FeatureFieldNotConstString MultipleEndpointsPerFile \
+ ModuleAssemblyNamingViolation MissingContractsAssembly MissingEndpointRouteConst \
+ EntityNotInContractsAssembly; do
+ # Only replace inside Diagnostic.Create( arg list, where the descriptor is the 1st arg
+ sed -i '' "s/Diagnostic\.Create(\\n\\s*${name}/Diagnostic.Create(\\n DiagnosticDescriptors.${name}/g" \
+ framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
+done
+```
+
+If `sed` is awkward, do it manually — there are ~60 call sites.
+
+**Note:** If any test file (e.g. `DiagnosticTests.cs`) references a descriptor by its old path (`DiagnosticEmitter.DuplicateDbSetPropertyName`), update those too. Check with:
+```bash
+grep -n "DiagnosticEmitter\." tests/SimpleModule.Generator.Tests/*.cs
+```
+
+- [ ] **Step 4: Build**
+
+Run: `dotnet build framework/SimpleModule.Generator tests/SimpleModule.Generator.Tests`
+Expected: Build succeeds with zero new warnings.
+
+- [ ] **Step 5: Run generator tests**
+
+Run: `dotnet test tests/SimpleModule.Generator.Tests`
+Expected: All tests pass. The diagnostic IDs and messages are unchanged.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.cs \
+ framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs \
+ tests/SimpleModule.Generator.Tests/
+git commit -m "refactor(generator): extract 38 DiagnosticDescriptors into their own file"
+```
+
+---
+
+## Task 17: Extract `Emitters/Diagnostics/ModuleChecks.cs`
+
+Move module-related checks (SM0002 empty name, SM0040 duplicate, SM0043 empty module).
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Emitters/Diagnostics/ModuleChecks.cs`
+- Modify: `framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs`
+
+- [ ] **Step 1: Create ModuleChecks**
+
+Write `framework/SimpleModule.Generator/Emitters/Diagnostics/ModuleChecks.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class ModuleChecks
+{
+ internal static void Run(SourceProductionContext context, DiscoveryData data)
+ {
+ // SM0002: Empty module name
+ foreach (var module in data.Modules)
+ {
+ if (string.IsNullOrEmpty(module.ModuleName))
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ DiagnosticDescriptors.EmptyModuleName,
+ LocationHelper.ToLocation(module.Location),
+ TypeMappingHelpers.StripGlobalPrefix(module.FullyQualifiedName)
+ )
+ );
+ }
+ }
+
+ // SM0040: Duplicate module name
+ var seenModuleNames = new Dictionary();
+ foreach (var module in data.Modules)
+ {
+ if (string.IsNullOrEmpty(module.ModuleName))
+ continue;
+
+ if (seenModuleNames.TryGetValue(module.ModuleName, out var existingFqn))
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ DiagnosticDescriptors.DuplicateModuleName,
+ LocationHelper.ToLocation(module.Location),
+ module.ModuleName,
+ TypeMappingHelpers.StripGlobalPrefix(existingFqn),
+ TypeMappingHelpers.StripGlobalPrefix(module.FullyQualifiedName)
+ )
+ );
+ }
+ else
+ {
+ seenModuleNames[module.ModuleName] = module.FullyQualifiedName;
+ }
+ }
+
+ // SM0043: Empty module warning
+ var moduleNamesWithDbContext = new HashSet(
+ StringComparer.Ordinal
+ );
+ foreach (var db in data.DbContexts)
+ moduleNamesWithDbContext.Add(db.ModuleName);
+
+ foreach (var module in data.Modules)
+ {
+ if (
+ !module.HasConfigureServices
+ && !module.HasConfigureEndpoints
+ && !module.HasConfigureMenu
+ && !module.HasConfigurePermissions
+ && !module.HasConfigureMiddleware
+ && !module.HasConfigureSettings
+ && !module.HasConfigureFeatureFlags
+ && module.Endpoints.Length == 0
+ && module.Views.Length == 0
+ && !moduleNamesWithDbContext.Contains(module.ModuleName)
+ )
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ DiagnosticDescriptors.EmptyModuleWarning,
+ LocationHelper.ToLocation(module.Location),
+ module.ModuleName
+ )
+ );
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Remove the moved checks from `DiagnosticEmitter.Emit`**
+
+In `DiagnosticEmitter.cs`, delete:
+- The SM0002 loop (currently the first block in `Emit`)
+- The SM0040 loop
+- The SM0043 loop (which includes the `moduleNamesWithDbContext` HashSet build)
+
+- [ ] **Step 3: Call `ModuleChecks.Run` at the top of `DiagnosticEmitter.Emit`**
+
+Insert at the very top of the `Emit` method body (before any other check):
+```csharp
+ModuleChecks.Run(context, data);
+```
+
+- [ ] **Step 4: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Emitters/Diagnostics/ModuleChecks.cs \
+ framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
+git commit -m "refactor(generator): extract ModuleChecks (SM0002/0040/0043)"
+```
+
+---
+
+## Task 18: Extract `Emitters/Diagnostics/DbContextChecks.cs`
+
+Covers SM0001, SM0003, SM0005, SM0006, SM0007, SM0054 (last one: `EntityNotInContractsAssembly`).
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Emitters/Diagnostics/DbContextChecks.cs`
+- Modify: `framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs`
+
+- [ ] **Step 1: Create DbContextChecks**
+
+Write `framework/SimpleModule.Generator/Emitters/Diagnostics/DbContextChecks.cs`. Copy the following blocks from the current `DiagnosticEmitter.Emit` body into a single `internal static void Run(SourceProductionContext context, DiscoveryData data)` method, preserving order:
+
+1. SM0001 — Duplicate DbSet property name across modules (currently in the middle of Emit; search for `DuplicateDbSetPropertyName`).
+2. SM0003 — Multiple IdentityDbContexts.
+3. SM0005 — IdentityDbContext with wrong type args.
+4. SM0054 (`EntityNotInContractsAssembly`) — the block with `allEntityFqns` HashSet (keep `allEntityFqns` inside the method so SM0006 can still use it).
+5. SM0006 — Entity config for entity not in any DbSet.
+6. SM0007 — Duplicate EntityTypeConfiguration.
+
+Reference all descriptors as `DiagnosticDescriptors.Xxx`. Replace `Strip(...)` with `TypeMappingHelpers.StripGlobalPrefix(...)`.
+
+File header:
+```csharp
+using System;
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class DbContextChecks
+{
+ internal static void Run(SourceProductionContext context, DiscoveryData data)
+ {
+ // (Paste the 6 blocks here, in the order above.)
+ }
+}
+```
+
+Look up the exact code for each block in the current `DiagnosticEmitter.Emit` — do not improvise. SM0001 does not currently appear in `DiagnosticEmitter.Emit` as a visible block; search `grep -n DuplicateDbSetPropertyName framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs` — if it's only the descriptor with no emit logic, it's reserved for a future check and can be omitted here (no emit call to move).
+
+- [ ] **Step 2: Remove the moved blocks from `DiagnosticEmitter.Emit`**
+
+Delete the six blocks listed above from `DiagnosticEmitter.Emit`.
+
+- [ ] **Step 3: Add `DbContextChecks.Run(context, data);` after `ModuleChecks.Run(...)`**
+
+- [ ] **Step 4: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Emitters/Diagnostics/DbContextChecks.cs \
+ framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
+git commit -m "refactor(generator): extract DbContextChecks (SM0003/0005/0006/0007/0054)"
+```
+
+---
+
+## Task 19: Extract `Emitters/Diagnostics/DependencyChecks.cs`
+
+Covers SM0010 (circular) and SM0011 (illegal references). Kept separate from the contract/DTO group because both depend on `TopologicalSort`.
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Emitters/Diagnostics/DependencyChecks.cs`
+- Modify: `framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs`
+
+- [ ] **Step 1: Create DependencyChecks**
+
+Write `framework/SimpleModule.Generator/Emitters/Diagnostics/DependencyChecks.cs`. Copy the SM0010 block (from the current `Emit`, where `TopologicalSort.SortModulesWithResult(data)` is called) and the SM0011 block (`foreach (var illegal in data.IllegalReferences)`). Reference descriptors as `DiagnosticDescriptors.Xxx`.
+
+```csharp
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class DependencyChecks
+{
+ internal static void Run(SourceProductionContext context, DiscoveryData data)
+ {
+ // Paste SM0010 (circular dependency) block here.
+ // Paste SM0011 (illegal implementation reference) block here.
+ }
+}
+```
+
+- [ ] **Step 2: Remove the moved blocks from `DiagnosticEmitter.Emit`**
+
+- [ ] **Step 3: Add `DependencyChecks.Run(context, data);` after `DbContextChecks.Run(...)`**
+
+- [ ] **Step 4: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Emitters/Diagnostics/DependencyChecks.cs \
+ framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
+git commit -m "refactor(generator): extract DependencyChecks (SM0010/0011)"
+```
+
+---
+
+## Task 20: Extract `Emitters/Diagnostics/ContractAndDtoChecks.cs`
+
+Covers SM0012/0013 (contract size), SM0014 (missing contract interfaces), SM0025/0026/0028/0029 (implementations), SM0035 (DTO no properties), SM0038 (infrastructure in contracts).
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Emitters/Diagnostics/ContractAndDtoChecks.cs`
+- Modify: `framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs`
+
+- [ ] **Step 1: Create ContractAndDtoChecks**
+
+Write `framework/SimpleModule.Generator/Emitters/Diagnostics/ContractAndDtoChecks.cs`. Copy these blocks, in order, from the current `DiagnosticEmitter.Emit` into a single `Run` method:
+
+1. SM0012/SM0013 — Contract interface size (reference `ContractInterfaceTooLargeError`, `ContractInterfaceTooLargeWarning`). Includes helper `ExtractShortName` — **move that helper here too** as `private static string ExtractShortName(...)`.
+2. SM0014 — Missing contract interfaces.
+3. SM0025/SM0026/SM0028/SM0029 — Contract implementation diagnostics (the block with `implsByInterface` Dictionary).
+4. SM0035 — DTO type with no public properties.
+5. SM0038 — Infrastructure type in Contracts.
+
+File header:
+```csharp
+using System;
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class ContractAndDtoChecks
+{
+ internal static void Run(SourceProductionContext context, DiscoveryData data)
+ {
+ // Paste the 5 blocks here, in order.
+ }
+
+ private static string ExtractShortName(string interfaceName)
+ {
+ var name = TypeMappingHelpers.StripGlobalPrefix(interfaceName);
+ if (name.Contains("."))
+ name = name.Substring(name.LastIndexOf('.') + 1);
+ if (name.StartsWith("I", StringComparison.Ordinal) && name.Length > 1)
+ name = name.Substring(1);
+ if (name.EndsWith("Contracts", StringComparison.Ordinal))
+ name = name.Substring(0, name.Length - "Contracts".Length);
+ return name;
+ }
+}
+```
+
+- [ ] **Step 2: Remove the moved blocks and `ExtractShortName` from `DiagnosticEmitter.cs`**
+
+- [ ] **Step 3: Add `ContractAndDtoChecks.Run(context, data);` after `DependencyChecks.Run(...)`**
+
+- [ ] **Step 4: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Emitters/Diagnostics/ContractAndDtoChecks.cs \
+ framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
+git commit -m "refactor(generator): extract ContractAndDtoChecks"
+```
+
+---
+
+## Task 21: Extract `Emitters/Diagnostics/PermissionFeatureChecks.cs`
+
+Covers SM0027/0031/0032/0033/0034 (permissions), SM0044 (multiple IModuleOptions), SM0045/0046/0047/0048 (features).
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Emitters/Diagnostics/PermissionFeatureChecks.cs`
+- Modify: `framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs`
+
+- [ ] **Step 1: Create PermissionFeatureChecks**
+
+Write `framework/SimpleModule.Generator/Emitters/Diagnostics/PermissionFeatureChecks.cs`. Copy the three blocks in order:
+
+1. SM0027/0031/0032/0033/0034 — Permission diagnostics (the block starting with `// SM0027/SM0031/SM0032/SM0033/SM0034: Permission diagnostics` and ending before `// SM0035: DTO type in contracts with no public properties`).
+2. SM0044 — Multiple IModuleOptions (the block starting with `var optionsByModule = ModuleOptionsRecord.GroupByModule(data.ModuleOptions);`).
+3. SM0045/0046/0047/0048 — Feature flag diagnostics (the block starting with `// SM0045/SM0046/SM0047/SM0048: Feature flag diagnostics`).
+
+```csharp
+using System;
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class PermissionFeatureChecks
+{
+ internal static void Run(SourceProductionContext context, DiscoveryData data)
+ {
+ // Paste permission, options, feature blocks here.
+ }
+}
+```
+
+- [ ] **Step 2: Remove the moved blocks from `DiagnosticEmitter.Emit`**
+
+- [ ] **Step 3: Add `PermissionFeatureChecks.Run(context, data);` after `ContractAndDtoChecks.Run(...)`**
+
+- [ ] **Step 4: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Emitters/Diagnostics/PermissionFeatureChecks.cs \
+ framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
+git commit -m "refactor(generator): extract PermissionFeatureChecks"
+```
+
+---
+
+## Task 22: Extract `Emitters/Diagnostics/EndpointChecks.cs`
+
+Covers SM0015 (duplicate view page), SM0039 (interceptor depends on DbContext-contract), SM0041 (view page prefix mismatch), SM0042 (views without ViewPrefix), SM0049 (multiple endpoints per file), SM0052/0053/0054 (assembly naming / missing contracts / missing Route const).
+
+**Files:**
+- Create: `framework/SimpleModule.Generator/Emitters/Diagnostics/EndpointChecks.cs`
+- Modify: `framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs`
+
+- [ ] **Step 1: Create EndpointChecks**
+
+Write `framework/SimpleModule.Generator/Emitters/Diagnostics/EndpointChecks.cs`. Copy these blocks, in order:
+
+1. SM0015 — Duplicate view page name (block starting `// SM0015: Duplicate view page name across modules`).
+2. SM0041 — View page prefix mismatch (block starting `// SM0041: View page prefix must match module name`).
+3. SM0042 — Module with views but no ViewPrefix (block starting `// SM0042: Module with views but no ViewPrefix`).
+4. SM0039 — Interceptor depends on DbContext-contract (block starting `// SM0039: Interceptor depends on contract whose implementation takes a DbContext`).
+5. SM0049 — Multiple endpoints per file (block starting `// SM0049: Multiple endpoints`).
+6. SM0052/0053/0054 — Assembly naming + missing contracts + missing Route const (the final `if (hostIsFramework)` block including all nested checks).
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class EndpointChecks
+{
+ internal static void Run(SourceProductionContext context, DiscoveryData data)
+ {
+ // Paste the 6 blocks here, in order.
+ }
+}
+```
+
+- [ ] **Step 2: Remove the moved blocks from `DiagnosticEmitter.Emit`**
+
+- [ ] **Step 3: Add `EndpointChecks.Run(context, data);` after `PermissionFeatureChecks.Run(...)`**
+
+- [ ] **Step 4: Check DiagnosticEmitter size**
+
+Run: `wc -l framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs`
+Expected: ≤ 80 lines. If higher, some blocks are still inline — move them.
+
+- [ ] **Step 5: Remove unused imports and `Strip` helper from DiagnosticEmitter.cs**
+
+`DiagnosticEmitter.Emit` should now be just:
+```csharp
+public void Emit(SourceProductionContext context, DiscoveryData data)
+{
+ ModuleChecks.Run(context, data);
+ DbContextChecks.Run(context, data);
+ DependencyChecks.Run(context, data);
+ ContractAndDtoChecks.Run(context, data);
+ PermissionFeatureChecks.Run(context, data);
+ EndpointChecks.Run(context, data);
+}
+```
+
+Delete the `Strip` private helper and any unused `using`s. The final file should look like:
+```csharp
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal sealed class DiagnosticEmitter : IEmitter
+{
+ public void Emit(SourceProductionContext context, DiscoveryData data)
+ {
+ ModuleChecks.Run(context, data);
+ DbContextChecks.Run(context, data);
+ DependencyChecks.Run(context, data);
+ ContractAndDtoChecks.Run(context, data);
+ PermissionFeatureChecks.Run(context, data);
+ EndpointChecks.Run(context, data);
+ }
+}
+```
+
+- [ ] **Step 6: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 7: Diff generated output against baseline**
+
+```bash
+dotnet build template/SimpleModule.Host -c Debug
+GEN_DIR=$(find template/SimpleModule.Host/obj/Debug -type d -name "SimpleModule.Generator" | head -1)
+diff -r baseline/generator-output "$GEN_DIR" | head -100
+```
+Expected: No output (identical).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Emitters/Diagnostics/EndpointChecks.cs \
+ framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
+git commit -m "refactor(generator): extract EndpointChecks, trim DiagnosticEmitter to orchestrator"
+```
+
+---
+
+## Task 23: Perf win — single-pass reference classification
+
+Today `compilation.References` is iterated three times (module scan, DTO scan, contracts scan). Consolidate into one pass up front.
+
+**Files:**
+- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`
+
+- [ ] **Step 1: Add the one-pass classification block near the top of `Extract`**
+
+Right after `var s = symbols.Value;`, add:
+
+```csharp
+ // Single-pass reference classification. Every discovery phase that scans
+ // referenced assemblies gets to reuse these pre-classified lists instead
+ // of re-iterating compilation.References + re-calling GetAssemblyOrModuleSymbol.
+ var refAssemblies = new List(compilation.References.Count());
+ var contractsAssemblies = new List();
+ foreach (var reference in compilation.References)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (
+ compilation.GetAssemblyOrModuleSymbol(reference)
+ is not IAssemblySymbol asm
+ )
+ continue;
+
+ refAssemblies.Add(asm);
+ if (asm.Name.EndsWith(".Contracts", StringComparison.OrdinalIgnoreCase))
+ contractsAssemblies.Add(asm);
+ }
+```
+
+- [ ] **Step 2: Replace the first reference loop (module discovery)**
+
+Find the loop `foreach (var reference in compilation.References)` that immediately precedes `FindModuleTypes` on line ~84. Replace the loop body with one that iterates `refAssemblies`:
+
+```csharp
+ foreach (var assemblySymbol in refAssemblies)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ ModuleFinder.FindModuleTypes(
+ assemblySymbol.GlobalNamespace,
+ s,
+ modules,
+ cancellationToken
+ );
+ }
+```
+
+- [ ] **Step 3: Replace the DTO reference loop**
+
+Find the second `foreach (var reference in compilation.References)` (inside the `if (dtoAttributeSymbol is not null)` / now `if (s.DtoAttribute is not null)` block). Replace with:
+
+```csharp
+ foreach (var assemblySymbol in refAssemblies)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ DtoFinder.FindDtoTypes(
+ assemblySymbol.GlobalNamespace,
+ s.DtoAttribute,
+ dtoTypes,
+ cancellationToken
+ );
+ }
+```
+
+- [ ] **Step 4: Replace the contracts-assembly building**
+
+Find the loop starting `// Step 2: Build contracts-to-module map` that iterates `compilation.References`. Replace that loop with one that uses `contractsAssemblies`:
+
+```csharp
+ // Step 2: Build contracts-to-module map
+ var contractsAssemblyMap = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var contractsAssemblySymbols = new Dictionary(
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ foreach (var asm in contractsAssemblies)
+ {
+ var asmName = asm.Name;
+ var baseName = asmName.Substring(0, asmName.Length - ".Contracts".Length);
+
+ // Try exact match on assembly name
+ if (moduleAssemblyMap.TryGetValue(baseName, out var moduleName))
+ {
+ contractsAssemblyMap[asmName] = moduleName;
+ contractsAssemblySymbols[asmName] = asm;
+ continue;
+ }
+
+ // Try matching last segment of baseName to module names (case-insensitive)
+ var lastDot = baseName.LastIndexOf('.');
+ var lastSegment = lastDot >= 0 ? baseName.Substring(lastDot + 1) : baseName;
+
+ foreach (var kvp in moduleAssemblyMap)
+ {
+ if (string.Equals(lastSegment, kvp.Value, StringComparison.OrdinalIgnoreCase))
+ {
+ contractsAssemblyMap[asmName] = kvp.Value;
+ contractsAssemblySymbols[asmName] = asm;
+ break;
+ }
+ }
+ }
+```
+
+- [ ] **Step 5: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 6: Diff against baseline**
+
+```bash
+dotnet build template/SimpleModule.Host -c Debug
+GEN_DIR=$(find template/SimpleModule.Host/obj/Debug -type d -name "SimpleModule.Generator" | head -1)
+diff -r baseline/generator-output "$GEN_DIR" | head -50
+```
+Expected: No output.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+git commit -m "perf(generator): single-pass reference classification, eliminate 2 reference re-iterations"
+```
+
+---
+
+## Task 24: Perf win — modules-by-name dictionary
+
+Today `modules.Find(m => m.ModuleName == ownerName)` inside endpoint/view loops is O(N·M). Replace with a `Dictionary` built once.
+
+**Files:**
+- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`
+
+- [ ] **Step 1: Build the dictionary after modules are discovered**
+
+Right after the module-symbols-by-FQN dictionary is built (currently lines ~121-128 of the pre-refactor file — locate with `grep -n "moduleSymbols = new Dictionary" framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`), add:
+
+```csharp
+ // Dictionary by module NAME for O(1) endpoint/view attribution below.
+ // Duplicate names are already caught by SM0040 — we just take the first entry.
+ var modulesByName = new Dictionary(StringComparer.Ordinal);
+ foreach (var module in modules)
+ {
+ if (!modulesByName.ContainsKey(module.ModuleName))
+ modulesByName[module.ModuleName] = module;
+ }
+```
+
+- [ ] **Step 2: Replace both `modules.Find(...)` call sites**
+
+Find the two lines that read:
+```csharp
+var owner = modules.Find(m => m.ModuleName == ownerName);
+```
+
+Replace each with:
+```csharp
+modulesByName.TryGetValue(ownerName, out var owner);
+```
+
+And update the subsequent `if (owner is not null)` usage — it stays valid since `TryGetValue` leaves `owner` null on miss.
+
+- [ ] **Step 3: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 4: Diff against baseline**
+
+```bash
+dotnet build template/SimpleModule.Host -c Debug
+GEN_DIR=$(find template/SimpleModule.Host/obj/Debug -type d -name "SimpleModule.Generator" | head -1)
+diff -r baseline/generator-output "$GEN_DIR" | head -20
+```
+Expected: No output.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+git commit -m "perf(generator): use modules-by-name dictionary for endpoint/view attribution"
+```
+
+---
+
+## Task 25: Perf win — lift `moduleNsByName` out of inner loop
+
+`moduleNsByName` is currently rebuilt inside the per-module endpoint-scan loop. Build it once.
+
+**Files:**
+- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`
+
+- [ ] **Step 1: Move the `moduleNsByName` build above the outer loop**
+
+Find the block:
+```csharp
+// Pre-compute module namespace per module name for page inference
+var moduleNsByName = new Dictionary();
+foreach (var m in modules)
+{
+ if (!moduleNsByName.ContainsKey(m.ModuleName))
+ {
+ var mFqn = TypeMappingHelpers.StripGlobalPrefix(m.FullyQualifiedName);
+ moduleNsByName[m.ModuleName] = TypeMappingHelpers.ExtractNamespace(mFqn);
+ }
+}
+```
+
+Currently sits inside `foreach (var module in modules)` near the view-matching code. Move it so it's declared **before** that outer `foreach (var module in modules)` loop — i.e. immediately after `modulesByName` is built in Task 24.
+
+- [ ] **Step 2: Confirm only one copy remains**
+
+Run: `grep -n "moduleNsByName = new Dictionary" framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`
+Expected: Exactly one match.
+
+- [ ] **Step 3: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 4: Diff against baseline**
+
+```bash
+dotnet build template/SimpleModule.Host -c Debug
+GEN_DIR=$(find template/SimpleModule.Host/obj/Debug -type d -name "SimpleModule.Generator" | head -1)
+diff -r baseline/generator-output "$GEN_DIR" | head -20
+```
+Expected: No output.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+git commit -m "perf(generator): lift moduleNsByName build out of per-module loop"
+```
+
+---
+
+## Task 26: Perf win — `FindClosestModuleName` reverse-index
+
+Replace the linear namespace scan with a pre-built `(namespace, moduleName)` list sorted by namespace-length descending.
+
+**Files:**
+- Modify: `framework/SimpleModule.Generator/Discovery/SymbolHelpers.cs`
+- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`
+
+- [ ] **Step 1: Add a new helper to SymbolHelpers.cs**
+
+In `SymbolHelpers.cs`, add (keep existing `FindClosestModuleName(string, List)` for now — it's the fallback):
+
+```csharp
+ ///
+ /// Pre-computed namespace→module-name index used by .
+ /// Entries are sorted by namespace length descending so the first startsWith match wins.
+ ///
+ internal readonly struct ModuleNamespaceIndex
+ {
+ internal readonly (string Namespace, string ModuleName)[] Entries;
+ internal readonly string FirstModuleName;
+
+ internal ModuleNamespaceIndex(
+ (string Namespace, string ModuleName)[] entries,
+ string firstModuleName
+ )
+ {
+ Entries = entries;
+ FirstModuleName = firstModuleName;
+ }
+ }
+
+ internal static ModuleNamespaceIndex BuildModuleNamespaceIndex(List modules)
+ {
+ var entries = new (string Namespace, string ModuleName)[modules.Count];
+ for (var i = 0; i < modules.Count; i++)
+ {
+ var moduleFqn = TypeMappingHelpers.StripGlobalPrefix(modules[i].FullyQualifiedName);
+ entries[i] = (TypeMappingHelpers.ExtractNamespace(moduleFqn), modules[i].ModuleName);
+ }
+
+ System.Array.Sort(
+ entries,
+ (a, b) => b.Namespace.Length.CompareTo(a.Namespace.Length)
+ );
+
+ return new ModuleNamespaceIndex(entries, modules[0].ModuleName);
+ }
+
+ internal static string FindClosestModuleNameFast(string typeFqn, ModuleNamespaceIndex index)
+ {
+ foreach (var (ns, moduleName) in index.Entries)
+ {
+ if (typeFqn.StartsWith(ns, System.StringComparison.Ordinal))
+ return moduleName;
+ }
+ return index.FirstModuleName;
+ }
+```
+
+- [ ] **Step 2: Build the index once in `Extract`**
+
+In `SymbolDiscovery.Extract`, right after `modulesByName` is built (Task 24), add:
+```csharp
+ var moduleNsIndex = SymbolHelpers.BuildModuleNamespaceIndex(modules);
+```
+
+- [ ] **Step 3: Replace `FindClosestModuleName` call sites**
+
+Find all call sites of `SymbolHelpers.FindClosestModuleName(xxx, modules)` in `Extract` (there are 4: endpoint attribution, view attribution, DbContext attribution, entity config attribution). Replace with:
+```csharp
+SymbolHelpers.FindClosestModuleNameFast(xxx, moduleNsIndex)
+```
+
+- [ ] **Step 4: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 5: Diff against baseline**
+
+```bash
+dotnet build template/SimpleModule.Host -c Debug
+GEN_DIR=$(find template/SimpleModule.Host/obj/Debug -type d -name "SimpleModule.Generator" | head -1)
+diff -r baseline/generator-output "$GEN_DIR" | head -20
+```
+Expected: No output.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/SymbolHelpers.cs \
+ framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+git commit -m "perf(generator): use pre-sorted namespace index for FindClosestModuleName"
+```
+
+---
+
+## Task 27: Perf win — DTO convention short-circuit
+
+Skip recursion into a type's nested namespace tree when its FQN is already claimed by the attributed-DTO scan.
+
+**Files:**
+- Modify: `framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs`
+
+- [ ] **Step 1: Read current `FindConventionDtoTypes` in `DtoFinder.cs`**
+
+The existing check already skips types whose FQN is in `existingFqns`. The short-circuit we add is: inside the per-namespace recursion, once we've processed a namespace, we don't revisit it. Since recursion is already single-pass, the real win here is smaller than estimated — **verify with a measurement before committing**.
+
+- [ ] **Step 2: Add a guard that skips child-namespace recursion when the namespace contains no public types**
+
+Find the recursion call inside `FindConventionDtoTypes`:
+```csharp
+if (member is INamespaceSymbol childNs)
+{
+ FindConventionDtoTypes(childNs, ...);
+}
+```
+
+Replace with:
+```csharp
+if (member is INamespaceSymbol childNs)
+{
+ // Skip walking into System.*, Microsoft.*, or Vogen.* trees — they never contain DTOs.
+ var childName = childNs.Name;
+ if (
+ childName == "System"
+ || childName == "Microsoft"
+ || childName == "Vogen"
+ )
+ continue;
+
+ FindConventionDtoTypes(
+ childNs,
+ noDtoAttrSymbol,
+ eventInterfaceSymbol,
+ existingFqns,
+ dtoTypes,
+ cancellationToken
+ );
+}
+```
+
+(Replace the parameter list `...` with the actual parameter list from the method — they haven't changed.)
+
+- [ ] **Step 3: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 4: Diff against baseline**
+
+```bash
+dotnet build template/SimpleModule.Host -c Debug
+GEN_DIR=$(find template/SimpleModule.Host/obj/Debug -type d -name "SimpleModule.Generator" | head -1)
+diff -r baseline/generator-output "$GEN_DIR" | head -30
+```
+Expected: No output. If there ARE differences (some module happens to be in `Microsoft.*`), revert this task.
+
+- [ ] **Step 5: Commit (or revert)**
+
+If diff is clean:
+```bash
+git add framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs
+git commit -m "perf(generator): skip System/Microsoft/Vogen trees in convention DTO scan"
+```
+
+If diff shows any change (user modules under those namespaces): `git checkout framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs` and skip this task.
+
+---
+
+## Task 28: Perf win — scoped attributed-DTO discovery
+
+Currently `DtoFinder.FindDtoTypes` scans every referenced assembly for `[Dto]`-attributed types. Restrict to module + host assemblies — contracts assemblies get the convention pass, and `[Dto]` attributes on framework/library types shouldn't be discovered as DTOs.
+
+**Files:**
+- Modify: `framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs`
+
+- [ ] **Step 1: Replace the `foreach (var assemblySymbol in refAssemblies)` DTO loop with a module-scoped scan**
+
+Find the loop (introduced in Task 23 Step 3):
+```csharp
+foreach (var assemblySymbol in refAssemblies)
+{
+ cancellationToken.ThrowIfCancellationRequested();
+
+ DtoFinder.FindDtoTypes(
+ assemblySymbol.GlobalNamespace,
+ s.DtoAttribute,
+ dtoTypes,
+ cancellationToken
+ );
+}
+```
+
+Replace with:
+```csharp
+// Only scan module assemblies (which host custom entities and request/response DTOs)
+// for [Dto]-attributed types. Contracts assemblies get the convention pass below.
+SymbolHelpers.ScanModuleAssemblies(
+ modules,
+ moduleSymbols,
+ (assembly, _) =>
+ DtoFinder.FindDtoTypes(
+ assembly.GlobalNamespace,
+ s.DtoAttribute!,
+ dtoTypes,
+ cancellationToken
+ )
+);
+```
+
+- [ ] **Step 2: Build and test**
+
+```bash
+dotnet build framework/SimpleModule.Generator
+dotnet test tests/SimpleModule.Generator.Tests
+```
+Expected: Build succeeds, all tests pass.
+
+- [ ] **Step 3: Diff against baseline**
+
+```bash
+dotnet build template/SimpleModule.Host -c Debug
+GEN_DIR=$(find template/SimpleModule.Host/obj/Debug -type d -name "SimpleModule.Generator" | head -1)
+diff -r baseline/generator-output "$GEN_DIR" | head -100
+```
+
+- If **no output**: commit.
+- If **any diff**: the DTO set changed. Revert:
+ ```bash
+ git checkout framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+ ```
+ Skip this task and note it in the commit log of Task 30.
+
+- [ ] **Step 4: Commit (conditional)**
+
+```bash
+git add framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+git commit -m "perf(generator): restrict [Dto] attribute scan to module assemblies only"
+```
+
+---
+
+## Task 29: Add incremental-caching test
+
+Locks in that `DiscoveryData` equality still works after the split.
+
+**Files:**
+- Create: `tests/SimpleModule.Generator.Tests/IncrementalCachingTests.cs`
+
+- [ ] **Step 1: Write the test**
+
+Write `tests/SimpleModule.Generator.Tests/IncrementalCachingTests.cs`:
+
+```csharp
+using System.Linq;
+using FluentAssertions;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using SimpleModule.Generator.Tests.Helpers;
+
+namespace SimpleModule.Generator.Tests;
+
+public class IncrementalCachingTests
+{
+ [Fact]
+ public void Generator_CachesDiscoveryData_OnIdenticalCompilation()
+ {
+ // Two-run pattern: first run populates the cache, second run should hit it.
+ var source = """
+ using SimpleModule.Core;
+ using Microsoft.Extensions.DependencyInjection;
+ using Microsoft.Extensions.Configuration;
+ using Microsoft.AspNetCore.Routing;
+
+ namespace TestApp;
+
+ [Module("Test")]
+ public class TestModule : IModule
+ {
+ public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { }
+ public void ConfigureEndpoints(IEndpointRouteBuilder endpoints) { }
+ }
+ """;
+
+ var compilation = GeneratorTestHelper.CreateCompilation(source);
+ var generator = new ModuleDiscovererGenerator();
+
+ GeneratorDriver driver = CSharpGeneratorDriver.Create(
+ generators: new[] { generator.AsSourceGenerator() },
+ driverOptions: new GeneratorDriverOptions(
+ disabledOutputs: IncrementalGeneratorOutputKind.None,
+ trackIncrementalGeneratorSteps: true
+ )
+ );
+
+ // First run — populate cache.
+ driver = driver.RunGenerators(compilation);
+
+ // Second run with the same compilation — should hit cache.
+ driver = driver.RunGenerators(compilation);
+ var result = driver.GetRunResult().Results[0];
+
+ // The RegisterSourceOutput step reuses prior output when its input is equal.
+ var outputs = result.TrackedOutputSteps.SelectMany(kvp => kvp.Value).ToList();
+ outputs.Should().NotBeEmpty("source outputs should be tracked");
+ outputs
+ .Should()
+ .OnlyContain(
+ step => step.Outputs.All(o => o.Reason == IncrementalStepRunReason.Cached
+ || o.Reason == IncrementalStepRunReason.Unchanged),
+ "second run with identical compilation must hit the cache"
+ );
+ }
+}
+```
+
+- [ ] **Step 2: Run the test**
+
+Run: `dotnet test tests/SimpleModule.Generator.Tests --filter "IncrementalCachingTests"`
+Expected: Pass.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/SimpleModule.Generator.Tests/IncrementalCachingTests.cs
+git commit -m "test(generator): lock in incremental caching behaviour after refactor"
+```
+
+---
+
+## Task 30: Add diagnostic-catalog reflection test
+
+Ensures no descriptor is accidentally dropped or changed during future edits.
+
+**Files:**
+- Create: `tests/SimpleModule.Generator.Tests/DiagnosticCatalogTests.cs`
+
+- [ ] **Step 1: Generate the baseline descriptor snapshot**
+
+From the repo root:
+```bash
+cat > /tmp/dump_descriptors.csx <<'EOF'
+// This script is not run by the test; it documents how the baseline was produced.
+// Run once manually on the pre-refactor commit to produce the expected table.
+EOF
+```
+
+(No action — just note that the baseline was captured in Task 1 Step 3.)
+
+- [ ] **Step 2: Write the test**
+
+Write `tests/SimpleModule.Generator.Tests/DiagnosticCatalogTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using FluentAssertions;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator.Tests;
+
+public class DiagnosticCatalogTests
+{
+ // Baseline captured from DiagnosticEmitter.cs pre-refactor.
+ // If you intentionally add/remove a diagnostic, update this table in the same commit
+ // and the docs for the new/removed ID.
+ private static readonly Dictionary Expected = new()
+ {
+ ["DuplicateDbSetPropertyName"] = ("SM0001", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["EmptyModuleName"] = ("SM0002", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["MultipleIdentityDbContexts"] = ("SM0003", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["IdentityDbContextBadTypeArgs"] = ("SM0005", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["EntityConfigForMissingEntity"] = ("SM0006", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["DuplicateEntityConfiguration"] = ("SM0007", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["CircularModuleDependency"] = ("SM0010", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["IllegalImplementationReference"] = ("SM0011", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["ContractInterfaceTooLargeWarning"] = ("SM0012", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["ContractInterfaceTooLargeError"] = ("SM0013", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["MissingContractInterfaces"] = ("SM0014", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["NoContractImplementation"] = ("SM0025", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["MultipleContractImplementations"] = ("SM0026", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["PermissionFieldNotConstString"] = ("SM0027", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["ContractImplementationNotPublic"] = ("SM0028", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["ContractImplementationIsAbstract"] = ("SM0029", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["PermissionValueBadPattern"] = ("SM0031", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["PermissionClassNotSealed"] = ("SM0032", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["DuplicatePermissionValue"] = ("SM0033", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["PermissionValueWrongPrefix"] = ("SM0034", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["DtoTypeNoProperties"] = ("SM0035", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["InfrastructureTypeInContracts"] = ("SM0038", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["DuplicateViewPageName"] = ("SM0015", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["InterceptorDependsOnDbContext"] = ("SM0039", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["DuplicateModuleName"] = ("SM0040", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["ViewPagePrefixMismatch"] = ("SM0041", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["ViewEndpointWithoutViewPrefix"] = ("SM0042", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["EmptyModuleWarning"] = ("SM0043", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["MultipleModuleOptions"] = ("SM0044", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["FeatureClassNotSealed"] = ("SM0045", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["FeatureFieldNamingViolation"] = ("SM0046", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["DuplicateFeatureName"] = ("SM0047", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["FeatureFieldNotConstString"] = ("SM0048", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["MultipleEndpointsPerFile"] = ("SM0049", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["ModuleAssemblyNamingViolation"] = ("SM0052", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ ["MissingContractsAssembly"] = ("SM0053", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["MissingEndpointRouteConst"] = ("SM0054", DiagnosticSeverity.Error, "SimpleModule.Generator"),
+ ["EntityNotInContractsAssembly"] = ("SM0055", DiagnosticSeverity.Warning, "SimpleModule.Generator"),
+ };
+
+ [Fact]
+ public void AllDescriptorsMatchBaseline()
+ {
+ var descriptorsType = typeof(ModuleDiscovererGenerator).Assembly
+ .GetType("SimpleModule.Generator.DiagnosticDescriptors");
+ descriptorsType.Should().NotBeNull("DiagnosticDescriptors class must exist in the generator assembly");
+
+ var actual = new Dictionary();
+ foreach (var field in descriptorsType!.GetFields(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public))
+ {
+ if (field.GetValue(null) is DiagnosticDescriptor d)
+ actual[field.Name] = (d.Id, d.DefaultSeverity, d.Category);
+ }
+
+ actual.Should().HaveCount(Expected.Count, "the set of descriptors must match the baseline");
+
+ foreach (var kvp in Expected)
+ {
+ actual.Should().ContainKey(kvp.Key);
+ actual[kvp.Key].Should().Be(kvp.Value, $"descriptor {kvp.Key} should match the baseline");
+ }
+ }
+}
+```
+
+**Note:** The ID/severity values in the `Expected` dictionary above are derived from reading `DiagnosticEmitter.cs` on the pre-refactor commit. If the reading is off, the test will fail on first run — inspect the failure, correct the baseline table in the *same commit*, and re-run.
+
+- [ ] **Step 3: Run the test**
+
+Run: `dotnet test tests/SimpleModule.Generator.Tests --filter "DiagnosticCatalogTests"`
+Expected: Pass. If any mismatch: inspect the failure, correct the `Expected` table (the test's baseline can only be wrong in one direction — the assertions tell you which fields don't match).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add tests/SimpleModule.Generator.Tests/DiagnosticCatalogTests.cs
+git commit -m "test(generator): lock in diagnostic catalog against baseline"
+```
+
+---
+
+## Task 31: Final size + output verification
+
+**Files:** (verification only)
+
+- [ ] **Step 1: Confirm all target files are under 300 lines**
+
+Run:
+```bash
+wc -l framework/SimpleModule.Generator/**/*.cs framework/SimpleModule.Generator/*.cs 2>/dev/null \
+ | awk '$1 > 300 && $2 != "total" { print }'
+```
+Expected: Empty output.
+
+- [ ] **Step 2: Confirm byte-identical generated source**
+
+Run:
+```bash
+dotnet build template/SimpleModule.Host -c Debug
+GEN_DIR=$(find template/SimpleModule.Host/obj/Debug -type d -name "SimpleModule.Generator" | head -1)
+diff -r baseline/generator-output "$GEN_DIR"
+```
+Expected: No output.
+
+- [ ] **Step 3: Full test suite**
+
+Run: `dotnet test`
+Expected: All tests pass (generator tests + integration tests).
+
+- [ ] **Step 4: Full solution build**
+
+Run: `dotnet build`
+Expected: Zero warnings, zero errors (repo enforces `TreatWarningsAsErrors`).
+
+- [ ] **Step 5: Remove the baseline snapshot**
+
+```bash
+rm -rf baseline/
+```
+No commit — `.gitignore` already excludes it.
+
+- [ ] **Step 6: Summarise the line-count improvements**
+
+Run:
+```bash
+echo "=== After refactor ==="
+wc -l framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs \
+ framework/SimpleModule.Generator/Discovery/DiscoveryData.cs \
+ framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
+echo ""
+echo "=== All generator files ==="
+find framework/SimpleModule.Generator -name "*.cs" | xargs wc -l | sort -rn | head -20
+```
+
+Expected: `SymbolDiscovery.cs` ≤ 200, `DiscoveryData.cs` ≤ 220, `DiagnosticEmitter.cs` ≤ 30. No file > 300.
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+
+| Spec requirement | Covered by task(s) |
+|---|---|
+| `SymbolDiscovery.cs` split to 12 files | Tasks 7–15 |
+| `DiscoveryData.cs` split to 3 files | Tasks 5–6 |
+| `DiagnosticEmitter.cs` split to 8 files | Tasks 16–22 |
+| `AssemblyConventions` relocation | Task 2 |
+| `CoreSymbols` record | Task 3, used in Task 4 + downstream |
+| Module-by-name dictionary | Task 24 |
+| Single-pass reference classification | Task 23 |
+| Lifted `moduleNsByName` | Task 25 |
+| `FindClosestModuleName` reverse-index | Task 26 |
+| DTO convention short-circuit | Task 27 |
+| Scoped attributed-DTO discovery (revert gate) | Task 28 |
+| Byte-identical generated output check | Tasks 4, 15, 22, 23, 24, 25, 26, 27, 28, 31 |
+| Diagnostic catalog reflection test | Task 30 |
+| Incremental-caching test | Task 29 |
+| Final line-count verification | Task 31 |
+
+All spec items mapped.
+
+**Placeholder scan:** No `TBD`, `TODO`, or "similar to above" phrasing. Every task has explicit file paths, commit messages, and build/test commands. Tasks that move code reference concrete method names and the current file's line ranges.
+
+**Type consistency check:**
+- `CoreSymbols.TryResolve` returns `CoreSymbols?` — Task 3 (definition) and Task 4 (usage) agree.
+- `ModuleFinder.FindModuleTypes(INamespaceSymbol, CoreSymbols, List, CancellationToken)` — Task 8 (definition) matches call site in Task 8 Step 3.
+- `EndpointFinder.FindEndpointTypes(INamespaceSymbol, CoreSymbols, List, List, CancellationToken)` — Task 9 signature matches its call site.
+- `SymbolHelpers.BuildModuleNamespaceIndex(List)` / `ModuleNamespaceIndex` / `FindClosestModuleNameFast` — defined in Task 26, used in Task 26.
+- `DiagnosticDescriptors.*` field names in Tasks 16–22 and Task 30 match exactly: 38 names listed, grep output from the explore step confirmed same count.
+- `ModuleChecks.Run` / `DbContextChecks.Run` / `DependencyChecks.Run` / `ContractAndDtoChecks.Run` / `PermissionFeatureChecks.Run` / `EndpointChecks.Run` — all six defined with `(SourceProductionContext context, DiscoveryData data)` signature, called in the Task 22 Step 5 orchestrator in that exact order.
+- `VogenFinder.IsVogenValueObject` / `VogenFinder.ResolveUnderlyingType` — Task 14 moves these out of `SymbolDiscovery`, Task 14 Step 5 fixes up the `DtoFinder` references that Task 10 left pointing at `SymbolDiscovery.IsVogenValueObject`.
+- `DtoFinder.FindDtoTypes(INamespaceSymbol, INamedTypeSymbol, List, CancellationToken)` — Task 10 preserves the pre-refactor signature; Task 23 call site passes `s.DtoAttribute` (non-null under the `if (s.DtoAttribute is not null)` guard); Task 28 call site uses `s.DtoAttribute!` for the same reason.
+
+**Scope:** Single focused refactor. No cross-repo concerns.
diff --git a/docs/superpowers/specs/2026-04-15-generator-split-and-perf-design.md b/docs/superpowers/specs/2026-04-15-generator-split-and-perf-design.md
new file mode 100644
index 00000000..69123ae0
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-15-generator-split-and-perf-design.md
@@ -0,0 +1,170 @@
+# Source Generator Split & Safe Perf Wins
+
+**Status:** Draft
+**Date:** 2026-04-15
+**Scope:** `framework/SimpleModule.Generator/`
+
+## Problem
+
+Three files in `SimpleModule.Generator` have grown well past the project's 300-line cap:
+
+| File | Lines |
+|---|---|
+| `Discovery/SymbolDiscovery.cs` | 2068 |
+| `Emitters/DiagnosticEmitter.cs` | 1294 |
+| `Discovery/DiscoveryData.cs` | 640 |
+| `Emitters/HostDbContextEmitter.cs` | 290 (borderline, out of scope) |
+
+`SymbolDiscovery.Extract` alone is ~790 lines with 30+ static helpers, mixing module/endpoint/DTO/DbContext/contract/permission/feature/interceptor/Vogen/agent discovery in one method. `DiagnosticEmitter` holds 38 `DiagnosticDescriptor` definitions and the matching check logic in a single class. `DiscoveryData` declares the top-level record plus every nested record type.
+
+Alongside the size problem, discovery itself does repeated work per invocation (it runs on every compilation change in the IDE):
+- ~15 `compilation.GetTypeByMetadataName` calls scattered through `Extract`
+- `compilation.References` iterated 3+ times
+- `FindClosestModuleName` does a linear scan over modules, called per endpoint/view/DbContext/entity-config
+- `moduleNsByName` rebuilt inside a per-module loop
+
+## Goal
+
+1. **No file over 300 lines** in `framework/SimpleModule.Generator/`.
+2. **Safe performance wins** — clarity-preserving improvements that reduce repeated work inside a single `Extract` call. No restructuring of the incremental pipeline topology.
+3. **Zero behavior change** — same diagnostics, same generated output, same test results.
+
+Explicitly **out of scope**: switching to `SyntaxProvider.ForAttributeWithMetadataName` (fundamentally different pipeline, can't see referenced assemblies' types); base-type-walk memoization; any change to the `CompilationProvider.Select` topology.
+
+## Design
+
+### File split
+
+#### `SymbolDiscovery.cs` (2068 → 12 files)
+
+| New file | Contents | Approx LOC |
+|---|---|---|
+| `Discovery/SymbolDiscovery.cs` | `Extract` thin orchestrator — resolves `CoreSymbols`, classifies references, calls finders in order, assembles `DiscoveryData` | ~180 |
+| `Discovery/CoreSymbols.cs` | **New.** Record holding all `GetTypeByMetadataName` lookups resolved once per `Extract` | ~80 |
+| `Discovery/Finders/ModuleFinder.cs` | `FindModuleTypes` + capability probing | ~200 |
+| `Discovery/Finders/EndpointFinder.cs` | `FindEndpointTypes`, `ReadRouteConstFields`, view-page inference | ~220 |
+| `Discovery/Finders/DtoFinder.cs` | `FindDtoTypes`, `FindConventionDtoTypes`, `ExtractDtoProperties`, `HasJsonIgnoreAttribute` | ~250 |
+| `Discovery/Finders/DbContextFinder.cs` | `FindDbContextTypes`, `FindEntityConfigTypes`, `HasDbContextConstructorParam` | ~250 |
+| `Discovery/Finders/ContractFinder.cs` | `ScanContractInterfaces`, `FindContractImplementations`, `GetContractLifetime`, contracts-assembly classification helpers | ~220 |
+| `Discovery/Finders/PermissionFeatureFinder.cs` | `FindPermissionClasses`, `FindFeatureClasses`, `FindModuleOptionsClasses` | ~220 |
+| `Discovery/Finders/InterceptorFinder.cs` | `FindInterceptorTypes` | ~60 |
+| `Discovery/Finders/VogenFinder.cs` | `FindVogenValueObjectsWithEfConverters`, `IsVogenValueObject`, `ResolveUnderlyingType` | ~150 |
+| `Discovery/Finders/AgentFinder.cs` | generic `FindImplementors`, agent/tool/knowledge wiring | ~80 |
+| `Discovery/SymbolHelpers.cs` | `ImplementsInterface`, `InheritsFrom`, `DeclaresMethod`, `FindClosestModuleName`, `ScanModuleAssemblies`, `GetSourceLocation`, `FindConcreteClassesImplementing` | ~200 |
+
+All finders remain `internal static` classes. No public surface change.
+
+#### `DiscoveryData.cs` (640 → 3 files)
+
+| New file | Contents | Approx LOC |
+|---|---|---|
+| `Discovery/DiscoveryData.cs` | `DiscoveryData` record + `Equals`/`GetHashCode` + `HashHelper` + `SourceLocationRecord` | ~200 |
+| `Discovery/Records/ModuleRecords.cs` | `ModuleInfoRecord`, `EndpointInfoRecord`, `ViewInfoRecord`, `ModuleDependencyRecord`, `IllegalModuleReferenceRecord` | ~150 |
+| `Discovery/Records/DataRecords.cs` | DTO, DbContext, Entity, Contract, Permission, Feature, Interceptor, Vogen, ModuleOptions, Agent records | ~290 |
+
+#### `DiagnosticEmitter.cs` (1294 → 8 files)
+
+| New file | Contents | Approx LOC |
+|---|---|---|
+| `Emitters/Diagnostics/DiagnosticEmitter.cs` | `IEmitter.Emit` — routes to checker classes in order | ~60 |
+| `Emitters/Diagnostics/DiagnosticDescriptors.cs` | All 38 `DiagnosticDescriptor` definitions, `internal static readonly` | ~280 |
+| `Emitters/Diagnostics/ModuleChecks.cs` | SM0002, 0040, 0043, 0049 + `Strip`/shared helpers | ~180 |
+| `Emitters/Diagnostics/DbContextChecks.cs` | SM0001, 0003, 0005, 0006, 0007, 0054 | ~220 |
+| `Emitters/Diagnostics/ContractAndDtoChecks.cs` | SM0008, 0009, 0011, 0012, 0013, 0022, 0023, 0053, missing-contracts-assembly | ~250 |
+| `Emitters/Diagnostics/PermissionFeatureChecks.cs` | SM0014–0020 (permissions), SM0041/0042/0044 (features), SM0051 (multiple options) | ~250 |
+| `Emitters/Diagnostics/EndpointChecks.cs` | SM0045, 0046, 0047, 0048, 0050 | ~130 |
+| `Emitters/Diagnostics/DependencyChecks.cs` | SM0010 (circular via `TopologicalSort`), illegal-reference checks | ~80 |
+
+`AssemblyConventions` (currently in `DiagnosticEmitter.cs`) moves to `Discovery/AssemblyConventions.cs` because both discovery and emission use it.
+
+### Safe perf wins
+
+Each is a small, orthogonal change with no effect on generated output.
+
+1. **`CoreSymbols` record** — one pass of `GetTypeByMetadataName` at the top of `Extract`, threaded through finders. Replaces ~15 scattered lookups.
+
+2. **Module-by-name dictionary** — build `Dictionary` once and replace `modules.Find(m => m.ModuleName == ownerName)` (currently called per endpoint and view).
+
+3. **Single-pass reference classification** — iterate `compilation.References` once, build `List refAssemblies` plus `List contractsAssemblies` upfront. Subsequent scans iterate pre-classified lists, cutting `GetAssemblyOrModuleSymbol` calls by ~3x.
+
+4. **Lift `moduleNsByName` above the loop** — it doesn't depend on the module being scanned.
+
+5. **`FindClosestModuleName` reverse-index** — build a `(string namespace, string moduleName)[]` sorted by namespace-length descending once; each call does a single forward scan until first match. Removes repeated substring work.
+
+6. **DTO convention-pass short-circuit** — skip recursion into namespaces whose full FQN is already in `existingDtoFqns` (common case: attributed DTOs already counted).
+
+7. **Scope attributed DTO discovery** — stop scanning every reference assembly for `[Dto]`-attributed types; only scan module + host assemblies. Contracts assemblies get the convention pass. Reverted if any test diffs.
+
+### Incremental caching (clarification, no change)
+
+The existing pipeline is:
+```
+CompilationProvider
+ → Select(Extract) // DiscoveryData (equatable)
+ → RegisterSourceOutput(Emit)
+```
+
+Discovery (`Extract`) runs on every compilation change. Emission is skipped when `DiscoveryData` is equal to the previous value. The perf wins above reduce the cost of `Extract`; they do not change what triggers it.
+
+## Verification
+
+### Automated gate (must pass)
+
+1. `dotnet build framework/SimpleModule.Generator` — zero new warnings under `TreatWarningsAsErrors`.
+2. `dotnet test tests/SimpleModule.Generator.Tests` — all 20 test files green.
+3. `dotnet test` at solution root — integration tests green.
+4. `dotnet build template/SimpleModule.Host` — generator output compiles.
+
+### Byte-identical generated output
+
+Before and after the refactor, dump the generated files from `SimpleModule.Host/obj/Debug/.../generated/SimpleModule.Generator/` and diff. Expected: identical modulo whitespace. Any semantic diff is a regression.
+
+Capture a `before/` snapshot on the pre-refactor commit and diff at each commit boundary.
+
+### Diagnostic catalog sanity
+
+Add a one-shot reflection test that enumerates all `DiagnosticDescriptor` static readonly fields in the Diagnostics namespace and asserts, against a baseline captured from `DiagnosticEmitter.cs` on the pre-refactor commit:
+- Same count (38)
+- Same set of IDs (don't enumerate expected IDs in source — snapshot at step 0, compare at every later commit)
+- Same severity and category per ID
+
+### Incremental caching test
+
+Add one test to `ModuleDiscovererGeneratorTests`:
+- Run the generator once, capture `GeneratorDriverRunResult.Results[0].TrackedSteps`.
+- Run again with the same compilation.
+- Assert the `RegisterSourceOutput` step reports `IncrementalStepRunReason.Cached`.
+
+Locks in that `DiscoveryData` equality still works. Fails loudly if a future change introduces a non-equatable field.
+
+## Commit cadence
+
+One commit per logical step, so regressions are bisect-friendly:
+
+1. Extract `CoreSymbols` record (perf + clarity lever)
+2. Split `DiscoveryData.cs` → records files
+3. Split `SymbolDiscovery.cs` → `Finders/*` + `SymbolHelpers` + thin orchestrator
+4. Split `DiagnosticEmitter.cs` → `DiagnosticDescriptors` + per-concern checkers
+5. Single-pass reference classification
+6. Module-by-name dictionary + lifted `moduleNsByName`
+7. `FindClosestModuleName` reverse-index
+8. DTO convention short-circuit + scoped attributed-DTO discovery (only if tests stay green)
+9. Add diagnostic-catalog reflection test
+10. Add incremental-caching test
+
+Each commit must leave `dotnet build && dotnet test` green.
+
+## Risks
+
+- **Hidden coupling between discovery passes.** If `FindDtoTypes` and `FindConventionDtoTypes` share state through the `dtoTypes` list in a way I haven't mapped, splitting may reorder output. Mitigation: byte-identical-output diff at each step.
+- **Perf win #7 (scoped attributed-DTO discovery) may change which DTOs are picked up.** Mitigation: if any test diff appears, revert that step; the rest of the design stands.
+- **`DiagnosticDescriptor` accessibility changes.** Today some descriptors are `internal` (referenced from tests) and some `private`. When moved to `DiagnosticDescriptors.cs`, all must be at least `internal`. Verify test references still resolve.
+- **`AssemblyConventions` relocation.** If any emitter or finder references it, the new `using` must be added. Caught by build.
+
+## Non-goals
+
+- Restructuring the incremental pipeline topology.
+- Migrating to `SyntaxProvider.ForAttributeWithMetadataName`.
+- Touching the 17 emitter classes that are already under 300 lines.
+- Splitting `HostDbContextEmitter.cs` (290 lines, under the cap).
+- Performance work beyond the seven items listed above.
diff --git a/framework/SimpleModule.Generator/Discovery/AssemblyConventions.cs b/framework/SimpleModule.Generator/Discovery/AssemblyConventions.cs
new file mode 100644
index 00000000..ab92e0cd
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/AssemblyConventions.cs
@@ -0,0 +1,32 @@
+using System;
+
+namespace SimpleModule.Generator;
+
+///
+/// Naming conventions for SimpleModule assemblies. Centralised so the same
+/// string literals don't drift between discovery code and diagnostic emission.
+///
+internal static class AssemblyConventions
+{
+ internal const string FrameworkPrefix = "SimpleModule.";
+ internal const string ContractsSuffix = ".Contracts";
+ internal const string ModuleSuffix = ".Module";
+
+ ///
+ /// Derives the `.Contracts` sibling assembly name for a SimpleModule
+ /// implementation assembly. Strips a trailing .Module suffix first
+ /// so SimpleModule.Agents.Module maps to
+ /// SimpleModule.Agents.Contracts instead of
+ /// SimpleModule.Agents.Module.Contracts.
+ ///
+ internal static string GetExpectedContractsAssemblyName(string implementationAssemblyName)
+ {
+ var baseName = implementationAssemblyName.EndsWith(ModuleSuffix, StringComparison.Ordinal)
+ ? implementationAssemblyName.Substring(
+ 0,
+ implementationAssemblyName.Length - ModuleSuffix.Length
+ )
+ : implementationAssemblyName;
+ return baseName + ContractsSuffix;
+ }
+}
diff --git a/framework/SimpleModule.Generator/Discovery/CoreSymbols.cs b/framework/SimpleModule.Generator/Discovery/CoreSymbols.cs
new file mode 100644
index 00000000..cf493e0c
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/CoreSymbols.cs
@@ -0,0 +1,88 @@
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+///
+/// Pre-resolved Roslyn type symbols needed during discovery. Resolving each
+/// symbol once via at the top
+/// of SymbolDiscovery.Extract is dramatically cheaper than scattering
+/// calls across finder methods — every call force-resolves the namespace
+/// chain, so caching them saves ~15 lookups per Extract invocation.
+///
+internal readonly record struct CoreSymbols(
+ INamedTypeSymbol ModuleAttribute,
+ INamedTypeSymbol? DtoAttribute,
+ INamedTypeSymbol? EndpointInterface,
+ INamedTypeSymbol? ViewEndpointInterface,
+ INamedTypeSymbol? AgentDefinition,
+ INamedTypeSymbol? AgentToolProvider,
+ INamedTypeSymbol? KnowledgeSource,
+ INamedTypeSymbol? ModuleServices,
+ INamedTypeSymbol? ModuleMenu,
+ INamedTypeSymbol? ModuleMiddleware,
+ INamedTypeSymbol? ModuleSettings,
+ INamedTypeSymbol? NoDtoAttribute,
+ INamedTypeSymbol? EventInterface,
+ INamedTypeSymbol? ModulePermissions,
+ INamedTypeSymbol? ModuleFeatures,
+ INamedTypeSymbol? SaveChangesInterceptor,
+ INamedTypeSymbol? ModuleOptions,
+ bool HasAgentsAssembly
+)
+{
+ ///
+ /// Resolves all framework type symbols from the current compilation.
+ /// Returns null if the ModuleAttribute itself isn't resolvable —
+ /// discovery cannot proceed without it.
+ ///
+ internal static CoreSymbols? TryResolve(Compilation compilation)
+ {
+ var moduleAttribute = compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.ModuleAttribute"
+ );
+ if (moduleAttribute is null)
+ return null;
+
+ return new CoreSymbols(
+ ModuleAttribute: moduleAttribute,
+ DtoAttribute: compilation.GetTypeByMetadataName("SimpleModule.Core.DtoAttribute"),
+ EndpointInterface: compilation.GetTypeByMetadataName("SimpleModule.Core.IEndpoint"),
+ ViewEndpointInterface: compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.IViewEndpoint"
+ ),
+ AgentDefinition: compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.Agents.IAgentDefinition"
+ ),
+ AgentToolProvider: compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.Agents.IAgentToolProvider"
+ ),
+ KnowledgeSource: compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.Rag.IKnowledgeSource"
+ ),
+ ModuleServices: compilation.GetTypeByMetadataName("SimpleModule.Core.IModuleServices"),
+ ModuleMenu: compilation.GetTypeByMetadataName("SimpleModule.Core.IModuleMenu"),
+ ModuleMiddleware: compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.IModuleMiddleware"
+ ),
+ ModuleSettings: compilation.GetTypeByMetadataName("SimpleModule.Core.IModuleSettings"),
+ NoDtoAttribute: compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.NoDtoGenerationAttribute"
+ ),
+ EventInterface: compilation.GetTypeByMetadataName("SimpleModule.Core.Events.IEvent"),
+ ModulePermissions: compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.Authorization.IModulePermissions"
+ ),
+ ModuleFeatures: compilation.GetTypeByMetadataName(
+ "SimpleModule.Core.FeatureFlags.IModuleFeatures"
+ ),
+ SaveChangesInterceptor: compilation.GetTypeByMetadataName(
+ "Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor"
+ ),
+ ModuleOptions: compilation.GetTypeByMetadataName("SimpleModule.Core.IModuleOptions"),
+ HasAgentsAssembly: compilation.GetTypeByMetadataName(
+ "SimpleModule.Agents.SimpleModuleAgentExtensions"
+ )
+ is not null
+ );
+ }
+}
diff --git a/framework/SimpleModule.Generator/Discovery/DependencyAnalyzer.cs b/framework/SimpleModule.Generator/Discovery/DependencyAnalyzer.cs
new file mode 100644
index 00000000..d0dc4304
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/DependencyAnalyzer.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class DependencyAnalyzer
+{
+ ///
+ /// Walks each module's referenced assemblies and classifies them:
+ ///
+ /// - A reference to another module's *implementation* assembly is illegal
+ /// and added to .
+ /// - A reference to another module's *.Contracts* assembly is a normal
+ /// dependency and added to .
+ ///
+ ///
+ internal static void Analyze(
+ List modules,
+ Dictionary moduleSymbols,
+ Dictionary moduleAssemblyMap,
+ Dictionary contractsAssemblyMap,
+ List dependencies,
+ List illegalReferences
+ )
+ {
+ foreach (var module in modules)
+ {
+ if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol))
+ continue;
+
+ var moduleAssembly = typeSymbol.ContainingAssembly;
+ var thisModuleName = module.ModuleName;
+
+ foreach (var asmModule in moduleAssembly.Modules)
+ {
+ foreach (var referencedAsm in asmModule.ReferencedAssemblySymbols)
+ {
+ var refName = referencedAsm.Name;
+
+ // Check for illegal direct module-to-module reference
+ if (
+ moduleAssemblyMap.TryGetValue(refName, out var referencedModuleName)
+ && !string.Equals(
+ referencedModuleName,
+ thisModuleName,
+ StringComparison.OrdinalIgnoreCase
+ )
+ )
+ {
+ illegalReferences.Add(
+ new IllegalModuleReferenceRecord(
+ thisModuleName,
+ moduleAssembly.Name,
+ referencedModuleName,
+ refName,
+ module.Location
+ )
+ );
+ }
+
+ // Check for dependency via contracts
+ if (
+ contractsAssemblyMap.TryGetValue(refName, out var depModuleName)
+ && !string.Equals(
+ depModuleName,
+ thisModuleName,
+ StringComparison.OrdinalIgnoreCase
+ )
+ )
+ {
+ dependencies.Add(
+ new ModuleDependencyRecord(thisModuleName, depModuleName, refName)
+ );
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs
index b01d6d81..e55d5741 100644
--- a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs
+++ b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs
@@ -1,5 +1,5 @@
-using System.Collections.Generic;
using System.Collections.Immutable;
+using System.Linq;
namespace SimpleModule.Generator;
@@ -132,509 +132,4 @@ public override int GetHashCode()
}
}
-internal readonly record struct ModuleInfoRecord(
- string FullyQualifiedName,
- string ModuleName,
- string AssemblyName,
- bool HasConfigureServices,
- bool HasConfigureEndpoints,
- bool HasConfigureMenu,
- bool HasConfigurePermissions,
- bool HasConfigureMiddleware,
- bool HasConfigureSettings,
- bool HasConfigureFeatureFlags,
- bool HasConfigureAgents,
- bool HasConfigureRateLimits,
- string RoutePrefix,
- string ViewPrefix,
- ImmutableArray Endpoints,
- ImmutableArray Views,
- SourceLocationRecord? Location
-)
-{
- public bool Equals(ModuleInfoRecord other)
- {
- return FullyQualifiedName == other.FullyQualifiedName
- && ModuleName == other.ModuleName
- && AssemblyName == other.AssemblyName
- && HasConfigureServices == other.HasConfigureServices
- && HasConfigureEndpoints == other.HasConfigureEndpoints
- && HasConfigureMenu == other.HasConfigureMenu
- && HasConfigureMiddleware == other.HasConfigureMiddleware
- && HasConfigurePermissions == other.HasConfigurePermissions
- && HasConfigureSettings == other.HasConfigureSettings
- && HasConfigureFeatureFlags == other.HasConfigureFeatureFlags
- && HasConfigureAgents == other.HasConfigureAgents
- && HasConfigureRateLimits == other.HasConfigureRateLimits
- && RoutePrefix == other.RoutePrefix
- && ViewPrefix == other.ViewPrefix
- && Endpoints.SequenceEqual(other.Endpoints)
- && Views.SequenceEqual(other.Views)
- && Location == other.Location;
- }
-
- public override int GetHashCode()
- {
- var hash = 17;
- hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode());
- hash = HashHelper.Combine(hash, (ModuleName ?? "").GetHashCode());
- hash = HashHelper.Combine(hash, (AssemblyName ?? "").GetHashCode());
- hash = HashHelper.Combine(hash, HasConfigureServices.GetHashCode());
- hash = HashHelper.Combine(hash, HasConfigureEndpoints.GetHashCode());
- hash = HashHelper.Combine(hash, HasConfigureMenu.GetHashCode());
- hash = HashHelper.Combine(hash, HasConfigureMiddleware.GetHashCode());
- hash = HashHelper.Combine(hash, HasConfigurePermissions.GetHashCode());
- hash = HashHelper.Combine(hash, HasConfigureSettings.GetHashCode());
- hash = HashHelper.Combine(hash, HasConfigureFeatureFlags.GetHashCode());
- hash = HashHelper.Combine(hash, HasConfigureAgents.GetHashCode());
- hash = HashHelper.Combine(hash, HasConfigureRateLimits.GetHashCode());
- hash = HashHelper.Combine(hash, (RoutePrefix ?? "").GetHashCode());
- hash = HashHelper.Combine(hash, (ViewPrefix ?? "").GetHashCode());
- hash = HashHelper.HashArray(hash, Endpoints);
- hash = HashHelper.HashArray(hash, Views);
- hash = HashHelper.Combine(hash, Location.GetHashCode());
- return hash;
- }
-}
-
-internal readonly record struct EndpointInfoRecord(
- string FullyQualifiedName,
- ImmutableArray RequiredPermissions,
- bool AllowAnonymous,
- string RouteTemplate,
- string HttpMethod
-)
-{
- public bool Equals(EndpointInfoRecord other)
- {
- return FullyQualifiedName == other.FullyQualifiedName
- && AllowAnonymous == other.AllowAnonymous
- && RouteTemplate == other.RouteTemplate
- && HttpMethod == other.HttpMethod
- && RequiredPermissions.SequenceEqual(other.RequiredPermissions);
- }
-
- public override int GetHashCode()
- {
- var hash = 17;
- hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode());
- hash = HashHelper.Combine(hash, AllowAnonymous.GetHashCode());
- hash = HashHelper.Combine(hash, (RouteTemplate ?? "").GetHashCode());
- hash = HashHelper.Combine(hash, (HttpMethod ?? "").GetHashCode());
- hash = HashHelper.HashArray(hash, RequiredPermissions);
- return hash;
- }
-}
-
-internal readonly record struct ViewInfoRecord(
- string FullyQualifiedName,
- string Page,
- string RouteTemplate,
- SourceLocationRecord? Location
-);
-
-internal readonly record struct DtoTypeInfoRecord(
- string FullyQualifiedName,
- string SafeName,
- string? BaseTypeFqn,
- ImmutableArray Properties
-)
-{
- public bool Equals(DtoTypeInfoRecord other)
- {
- return FullyQualifiedName == other.FullyQualifiedName
- && SafeName == other.SafeName
- && BaseTypeFqn == other.BaseTypeFqn
- && Properties.SequenceEqual(other.Properties);
- }
-
- public override int GetHashCode()
- {
- var hash = 17;
- hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode());
- hash = HashHelper.Combine(hash, SafeName.GetHashCode());
- hash = HashHelper.Combine(hash, BaseTypeFqn?.GetHashCode() ?? 0);
- hash = HashHelper.HashArray(hash, Properties);
- return hash;
- }
-}
-
-internal readonly record struct DtoPropertyInfoRecord(
- string Name,
- string TypeFqn,
- string? UnderlyingTypeFqn,
- bool HasSetter
-);
-
-internal readonly record struct DbContextInfoRecord(
- string FullyQualifiedName,
- string ModuleName,
- bool IsIdentityDbContext,
- string IdentityUserTypeFqn,
- string IdentityRoleTypeFqn,
- string IdentityKeyTypeFqn,
- ImmutableArray DbSets,
- SourceLocationRecord? Location
-)
-{
- public bool Equals(DbContextInfoRecord other)
- {
- return FullyQualifiedName == other.FullyQualifiedName
- && ModuleName == other.ModuleName
- && IsIdentityDbContext == other.IsIdentityDbContext
- && IdentityUserTypeFqn == other.IdentityUserTypeFqn
- && IdentityRoleTypeFqn == other.IdentityRoleTypeFqn
- && IdentityKeyTypeFqn == other.IdentityKeyTypeFqn
- && DbSets.SequenceEqual(other.DbSets)
- && Location == other.Location;
- }
-
- public override int GetHashCode()
- {
- var hash = 17;
- hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode());
- hash = HashHelper.Combine(hash, (ModuleName ?? "").GetHashCode());
- hash = HashHelper.Combine(hash, IsIdentityDbContext.GetHashCode());
- hash = HashHelper.Combine(hash, (IdentityUserTypeFqn ?? "").GetHashCode());
- hash = HashHelper.Combine(hash, (IdentityRoleTypeFqn ?? "").GetHashCode());
- hash = HashHelper.Combine(hash, (IdentityKeyTypeFqn ?? "").GetHashCode());
- hash = HashHelper.HashArray(hash, DbSets);
- hash = HashHelper.Combine(hash, Location.GetHashCode());
- return hash;
- }
-}
-
-internal readonly record struct DbSetInfoRecord(
- string PropertyName,
- string EntityFqn,
- string EntityAssemblyName,
- SourceLocationRecord? EntityLocation
-);
-
-internal readonly record struct EntityConfigInfoRecord(
- string ConfigFqn,
- string EntityFqn,
- string ModuleName,
- SourceLocationRecord? Location
-);
-
-internal readonly record struct ModuleDependencyRecord(
- string ModuleName,
- string DependsOnModuleName,
- string ContractsAssemblyName
-);
-
-internal readonly record struct IllegalModuleReferenceRecord(
- string ReferencingModuleName,
- string ReferencingAssemblyName,
- string ReferencedModuleName,
- string ReferencedAssemblyName,
- SourceLocationRecord? Location
-);
-
-internal readonly record struct ContractInterfaceInfoRecord(
- string ContractsAssemblyName,
- string InterfaceName,
- int MethodCount,
- SourceLocationRecord? Location
-);
-
-internal readonly record struct ContractImplementationRecord(
- string InterfaceFqn,
- string ImplementationFqn,
- string ModuleName,
- bool IsPublic,
- bool IsAbstract,
- bool DependsOnDbContext,
- SourceLocationRecord? Location,
- int Lifetime
-);
-
-internal readonly record struct PermissionClassRecord(
- string FullyQualifiedName,
- string ModuleName,
- bool IsSealed,
- ImmutableArray Fields,
- SourceLocationRecord? Location
-)
-{
- public bool Equals(PermissionClassRecord other) =>
- FullyQualifiedName == other.FullyQualifiedName
- && ModuleName == other.ModuleName
- && IsSealed == other.IsSealed
- && Fields.SequenceEqual(other.Fields)
- && Location == other.Location;
-
- public override int GetHashCode()
- {
- var hash = 17;
- hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode());
- hash = HashHelper.Combine(hash, (ModuleName ?? "").GetHashCode());
- hash = HashHelper.Combine(hash, IsSealed.GetHashCode());
- hash = HashHelper.HashArray(hash, Fields);
- hash = HashHelper.Combine(hash, Location.GetHashCode());
- return hash;
- }
-}
-
-internal readonly record struct PermissionFieldRecord(
- string FieldName,
- string Value,
- bool IsConstString,
- SourceLocationRecord? Location
-);
-
-internal readonly record struct FeatureClassRecord(
- string FullyQualifiedName,
- string ModuleName,
- bool IsSealed,
- ImmutableArray Fields,
- SourceLocationRecord? Location
-)
-{
- public bool Equals(FeatureClassRecord other) =>
- FullyQualifiedName == other.FullyQualifiedName
- && ModuleName == other.ModuleName
- && IsSealed == other.IsSealed
- && Fields.SequenceEqual(other.Fields)
- && Location == other.Location;
-
- public override int GetHashCode()
- {
- var hash = 17;
- hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode());
- hash = HashHelper.Combine(hash, (ModuleName ?? "").GetHashCode());
- hash = HashHelper.Combine(hash, IsSealed.GetHashCode());
- hash = HashHelper.HashArray(hash, Fields);
- hash = HashHelper.Combine(hash, Location.GetHashCode());
- return hash;
- }
-}
-
-internal readonly record struct FeatureFieldRecord(
- string FieldName,
- string Value,
- bool IsConstString,
- SourceLocationRecord? Location
-);
-
-internal readonly record struct InterceptorInfoRecord(
- string FullyQualifiedName,
- string ModuleName,
- ImmutableArray ConstructorParamTypeFqns,
- SourceLocationRecord? Location
-)
-{
- public bool Equals(InterceptorInfoRecord other) =>
- FullyQualifiedName == other.FullyQualifiedName
- && ModuleName == other.ModuleName
- && ConstructorParamTypeFqns.SequenceEqual(other.ConstructorParamTypeFqns)
- && Location == other.Location;
-
- public override int GetHashCode()
- {
- var hash = 17;
- hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode());
- hash = HashHelper.Combine(hash, (ModuleName ?? "").GetHashCode());
- hash = HashHelper.HashArray(hash, ConstructorParamTypeFqns);
- hash = HashHelper.Combine(hash, Location.GetHashCode());
- return hash;
- }
-}
-
-internal readonly record struct ModuleOptionsRecord(
- string FullyQualifiedName,
- string ModuleName,
- SourceLocationRecord? Location
-)
-{
- 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,
- string ConverterFqn,
- string ComparerFqn
-);
-
-internal readonly record struct AgentDefinitionRecord(string FullyQualifiedName, string ModuleName);
-
-internal readonly record struct AgentToolProviderRecord(
- string FullyQualifiedName,
- string ModuleName
-);
-
-internal readonly record struct KnowledgeSourceRecord(string FullyQualifiedName, string ModuleName);
-
-#endregion
-
-#region Mutable working types (used during symbol traversal only)
-
-internal sealed class ModuleInfo
-{
- public string FullyQualifiedName { get; set; } = "";
- public string ModuleName { get; set; } = "";
- public string AssemblyName { get; set; } = "";
- public bool HasConfigureServices { get; set; }
- public bool HasConfigureEndpoints { get; set; }
- public bool HasConfigureMenu { get; set; }
- public bool HasConfigurePermissions { get; set; }
- public bool HasConfigureMiddleware { get; set; }
- public bool HasConfigureSettings { get; set; }
- public bool HasConfigureFeatureFlags { get; set; }
- public bool HasConfigureAgents { get; set; }
- public bool HasConfigureRateLimits { get; set; }
- public string RoutePrefix { get; set; } = "";
- public string ViewPrefix { get; set; } = "";
- public List Endpoints { get; set; } = new();
- public List Views { get; set; } = new();
- public SourceLocationRecord? Location { get; set; }
-}
-
-internal sealed class EndpointInfo
-{
- public string FullyQualifiedName { get; set; } = "";
- public List RequiredPermissions { get; set; } = new();
- public bool AllowAnonymous { get; set; }
- public string RouteTemplate { get; set; } = "";
- public string HttpMethod { get; set; } = "";
-}
-
-internal sealed class ViewInfo
-{
- public string FullyQualifiedName { get; set; } = "";
- public string? Page { get; set; }
- public string InferredClassName { get; set; } = "";
- public string RouteTemplate { get; set; } = "";
- public SourceLocationRecord? Location { get; set; }
-}
-
-internal sealed class DtoTypeInfo
-{
- public string FullyQualifiedName { get; set; } = "";
- public string SafeName { get; set; } = "";
- public string? BaseTypeFqn { get; set; }
- public List Properties { get; set; } = new();
-}
-
-internal sealed class DtoPropertyInfo
-{
- public string Name { get; set; } = "";
- public string TypeFqn { get; set; } = "";
-
- ///
- /// For value objects (e.g. Vogen), the underlying primitive type FQN.
- /// Null if the type is not a value object wrapper.
- ///
- public string? UnderlyingTypeFqn { get; set; }
-
- public bool HasSetter { get; set; }
-}
-
-internal sealed class DbContextInfo
-{
- public string FullyQualifiedName { get; set; } = "";
- public string ModuleName { get; set; } = "";
- public bool IsIdentityDbContext { get; set; }
- public string IdentityUserTypeFqn { get; set; } = "";
- public string IdentityRoleTypeFqn { get; set; } = "";
- public string IdentityKeyTypeFqn { get; set; } = "";
- public List DbSets { get; set; } = new();
- public SourceLocationRecord? Location { get; set; }
-}
-
-internal sealed class DbSetInfo
-{
- public string PropertyName { get; set; } = "";
- public string EntityFqn { get; set; } = "";
- public string EntityAssemblyName { get; set; } = "";
- public SourceLocationRecord? EntityLocation { get; set; }
-}
-
-internal sealed class EntityConfigInfo
-{
- public string ConfigFqn { get; set; } = "";
- public string EntityFqn { get; set; } = "";
- public string ModuleName { get; set; } = "";
- public SourceLocationRecord? Location { get; set; }
-}
-
-internal sealed class ContractImplementationInfo
-{
- public string InterfaceFqn { get; set; } = "";
- public string ImplementationFqn { get; set; } = "";
- public string ModuleName { get; set; } = "";
- public bool IsPublic { get; set; }
- public bool IsAbstract { get; set; }
- public bool DependsOnDbContext { get; set; }
- public SourceLocationRecord? Location { get; set; }
- public int Lifetime { get; set; } = 1; // Default: Scoped (ServiceLifetime.Scoped = 1)
-}
-
-internal sealed class PermissionClassInfo
-{
- public string FullyQualifiedName { get; set; } = "";
- public string ModuleName { get; set; } = "";
- public bool IsSealed { get; set; }
- public List Fields { get; set; } = new();
- public SourceLocationRecord? Location { get; set; }
-}
-
-internal sealed class PermissionFieldInfo
-{
- public string FieldName { get; set; } = "";
- public string Value { get; set; } = "";
- public bool IsConstString { get; set; }
- public SourceLocationRecord? Location { get; set; }
-}
-
-internal sealed class FeatureClassInfo
-{
- public string FullyQualifiedName { get; set; } = "";
- public string ModuleName { get; set; } = "";
- public bool IsSealed { get; set; }
- public List Fields { get; set; } = new();
- public SourceLocationRecord? Location { get; set; }
-}
-
-internal sealed class FeatureFieldInfo
-{
- public string FieldName { get; set; } = "";
- public string Value { get; set; } = "";
- public bool IsConstString { get; set; }
- public SourceLocationRecord? Location { get; set; }
-}
-
-internal sealed class InterceptorInfo
-{
- public string FullyQualifiedName { get; set; } = "";
- public string ModuleName { get; set; } = "";
- public List ConstructorParamTypeFqns { get; set; } = new();
- public SourceLocationRecord? Location { get; set; }
-}
-
-///
-/// Shared mutable working type for discovered interface implementors (agents, tool providers, knowledge sources).
-///
-internal sealed class DiscoveredTypeInfo
-{
- public string FullyQualifiedName { get; set; } = "";
- public string ModuleName { get; set; } = "";
-}
-
#endregion
diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryDataBuilder.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryDataBuilder.cs
new file mode 100644
index 00000000..bf5e36d0
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/DiscoveryDataBuilder.cs
@@ -0,0 +1,181 @@
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+
+namespace SimpleModule.Generator;
+
+internal static class DiscoveryDataBuilder
+{
+ ///
+ /// Converts the mutable working collections gathered during discovery into an
+ /// equatable record with
+ /// fields. The equatable shape is required so the incremental generator pipeline
+ /// can compare results and skip regenerating when nothing changed.
+ ///
+ internal static DiscoveryData Build(
+ List modules,
+ List dtoTypes,
+ List dbContexts,
+ List entityConfigs,
+ List dependencies,
+ List illegalReferences,
+ List contractInterfaces,
+ List contractImplementations,
+ List permissionClasses,
+ List featureClasses,
+ List interceptors,
+ List vogenValueObjects,
+ List moduleOptionsList,
+ List agentDefinitions,
+ List agentToolProviders,
+ List knowledgeSources,
+ Dictionary contractsAssemblyMap,
+ bool hasAgentsAssembly,
+ string hostAssemblyName
+ )
+ {
+ return new DiscoveryData(
+ modules
+ .Select(m => new ModuleInfoRecord(
+ m.FullyQualifiedName,
+ m.ModuleName,
+ m.AssemblyName,
+ m.HasConfigureServices,
+ m.HasConfigureEndpoints,
+ m.HasConfigureMenu,
+ m.HasConfigurePermissions,
+ m.HasConfigureMiddleware,
+ m.HasConfigureSettings,
+ m.HasConfigureFeatureFlags,
+ m.HasConfigureAgents,
+ m.HasConfigureRateLimits,
+ m.RoutePrefix,
+ m.ViewPrefix,
+ m.Endpoints.Select(e => new EndpointInfoRecord(
+ e.FullyQualifiedName,
+ e.RequiredPermissions.ToImmutableArray(),
+ e.AllowAnonymous,
+ e.RouteTemplate,
+ e.HttpMethod
+ ))
+ .ToImmutableArray(),
+ m.Views.Select(v => new ViewInfoRecord(
+ v.FullyQualifiedName,
+ v.Page ?? "",
+ v.RouteTemplate,
+ v.Location
+ ))
+ .ToImmutableArray(),
+ m.Location
+ ))
+ .ToImmutableArray(),
+ dtoTypes
+ .Select(d => new DtoTypeInfoRecord(
+ d.FullyQualifiedName,
+ d.SafeName,
+ d.BaseTypeFqn,
+ d.Properties.Select(p => new DtoPropertyInfoRecord(
+ p.Name,
+ p.TypeFqn,
+ p.UnderlyingTypeFqn,
+ p.HasSetter
+ ))
+ .ToImmutableArray()
+ ))
+ .ToImmutableArray(),
+ dbContexts
+ .Select(c => new DbContextInfoRecord(
+ c.FullyQualifiedName,
+ c.ModuleName,
+ c.IsIdentityDbContext,
+ c.IdentityUserTypeFqn,
+ c.IdentityRoleTypeFqn,
+ c.IdentityKeyTypeFqn,
+ c.DbSets.Select(d => new DbSetInfoRecord(
+ d.PropertyName,
+ d.EntityFqn,
+ d.EntityAssemblyName,
+ d.EntityLocation
+ ))
+ .ToImmutableArray(),
+ c.Location
+ ))
+ .ToImmutableArray(),
+ entityConfigs
+ .Select(e => new EntityConfigInfoRecord(
+ e.ConfigFqn,
+ e.EntityFqn,
+ e.ModuleName,
+ e.Location
+ ))
+ .ToImmutableArray(),
+ dependencies.ToImmutableArray(),
+ illegalReferences.ToImmutableArray(),
+ contractInterfaces.ToImmutableArray(),
+ contractImplementations
+ .Select(c => new ContractImplementationRecord(
+ c.InterfaceFqn,
+ c.ImplementationFqn,
+ c.ModuleName,
+ c.IsPublic,
+ c.IsAbstract,
+ c.DependsOnDbContext,
+ c.Location,
+ c.Lifetime
+ ))
+ .ToImmutableArray(),
+ permissionClasses
+ .Select(p => new PermissionClassRecord(
+ p.FullyQualifiedName,
+ p.ModuleName,
+ p.IsSealed,
+ p.Fields.Select(f => new PermissionFieldRecord(
+ f.FieldName,
+ f.Value,
+ f.IsConstString,
+ f.Location
+ ))
+ .ToImmutableArray(),
+ p.Location
+ ))
+ .ToImmutableArray(),
+ featureClasses
+ .Select(f => new FeatureClassRecord(
+ f.FullyQualifiedName,
+ f.ModuleName,
+ f.IsSealed,
+ f.Fields.Select(ff => new FeatureFieldRecord(
+ ff.FieldName,
+ ff.Value,
+ ff.IsConstString,
+ ff.Location
+ ))
+ .ToImmutableArray(),
+ f.Location
+ ))
+ .ToImmutableArray(),
+ interceptors
+ .Select(i => new InterceptorInfoRecord(
+ i.FullyQualifiedName,
+ i.ModuleName,
+ i.ConstructorParamTypeFqns.ToImmutableArray(),
+ i.Location
+ ))
+ .ToImmutableArray(),
+ vogenValueObjects.ToImmutableArray(),
+ moduleOptionsList.ToImmutableArray(),
+ agentDefinitions
+ .Select(a => new AgentDefinitionRecord(a.FullyQualifiedName, a.ModuleName))
+ .ToImmutableArray(),
+ agentToolProviders
+ .Select(a => new AgentToolProviderRecord(a.FullyQualifiedName, a.ModuleName))
+ .ToImmutableArray(),
+ knowledgeSources
+ .Select(k => new KnowledgeSourceRecord(k.FullyQualifiedName, k.ModuleName))
+ .ToImmutableArray(),
+ contractsAssemblyMap.Keys.ToImmutableArray(),
+ hasAgentsAssembly,
+ hostAssemblyName
+ );
+ }
+}
diff --git a/framework/SimpleModule.Generator/Discovery/Finders/AgentFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/AgentFinder.cs
new file mode 100644
index 00000000..c752c765
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/Finders/AgentFinder.cs
@@ -0,0 +1,101 @@
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class AgentFinder
+{
+ internal static void FindImplementors(
+ INamespaceSymbol namespaceSymbol,
+ INamedTypeSymbol interfaceSymbol,
+ string moduleName,
+ List results
+ )
+ {
+ foreach (var member in namespaceSymbol.GetMembers())
+ {
+ if (member is INamespaceSymbol childNamespace)
+ {
+ FindImplementors(childNamespace, interfaceSymbol, moduleName, results);
+ }
+ else if (
+ member is INamedTypeSymbol typeSymbol
+ && !typeSymbol.IsAbstract
+ && typeSymbol.TypeKind == TypeKind.Class
+ && SymbolHelpers.ImplementsInterface(typeSymbol, interfaceSymbol)
+ )
+ {
+ results.Add(
+ new DiscoveredTypeInfo
+ {
+ FullyQualifiedName = typeSymbol.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ ),
+ ModuleName = moduleName,
+ }
+ );
+ }
+ }
+ }
+
+ ///
+ /// Scans every module's implementation assembly for three agent-related
+ /// interface implementors: IAgentDefinition, IAgentToolProvider,
+ /// IKnowledgeSource. Each is scanned independently; a null symbol in
+ /// skips that scan.
+ ///
+ internal static void DiscoverAll(
+ List modules,
+ Dictionary moduleSymbols,
+ CoreSymbols symbols,
+ List agentDefinitions,
+ List agentToolProviders,
+ List knowledgeSources
+ )
+ {
+ if (symbols.AgentDefinition is not null)
+ {
+ SymbolHelpers.ScanModuleAssemblies(
+ modules,
+ moduleSymbols,
+ (assembly, module) =>
+ FindImplementors(
+ assembly.GlobalNamespace,
+ symbols.AgentDefinition,
+ module.ModuleName,
+ agentDefinitions
+ )
+ );
+ }
+
+ if (symbols.AgentToolProvider is not null)
+ {
+ SymbolHelpers.ScanModuleAssemblies(
+ modules,
+ moduleSymbols,
+ (assembly, module) =>
+ FindImplementors(
+ assembly.GlobalNamespace,
+ symbols.AgentToolProvider,
+ module.ModuleName,
+ agentToolProviders
+ )
+ );
+ }
+
+ if (symbols.KnowledgeSource is not null)
+ {
+ SymbolHelpers.ScanModuleAssemblies(
+ modules,
+ moduleSymbols,
+ (assembly, module) =>
+ FindImplementors(
+ assembly.GlobalNamespace,
+ symbols.KnowledgeSource,
+ module.ModuleName,
+ knowledgeSources
+ )
+ );
+ }
+ }
+}
diff --git a/framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs
new file mode 100644
index 00000000..2e6f15f1
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs
@@ -0,0 +1,216 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class ContractFinder
+{
+ internal static void ScanContractInterfaces(
+ INamespaceSymbol namespaceSymbol,
+ string assemblyName,
+ List results
+ )
+ {
+ foreach (var member in namespaceSymbol.GetMembers())
+ {
+ if (member is INamespaceSymbol childNs)
+ {
+ ScanContractInterfaces(childNs, assemblyName, results);
+ }
+ else if (
+ member is INamedTypeSymbol typeSymbol
+ && typeSymbol.TypeKind == TypeKind.Interface
+ && typeSymbol.DeclaredAccessibility == Accessibility.Public
+ )
+ {
+ var methodCount = 0;
+ foreach (var m in typeSymbol.GetMembers())
+ {
+ if (m is IMethodSymbol ms && ms.MethodKind == MethodKind.Ordinary)
+ methodCount++;
+ }
+
+ results.Add(
+ new ContractInterfaceInfoRecord(
+ assemblyName,
+ typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
+ methodCount,
+ SymbolHelpers.GetSourceLocation(typeSymbol)
+ )
+ );
+ }
+ }
+ }
+
+ internal static void FindContractImplementations(
+ INamespaceSymbol namespaceSymbol,
+ HashSet contractInterfaceFqns,
+ string moduleName,
+ Compilation compilation,
+ List results
+ )
+ {
+ foreach (var member in namespaceSymbol.GetMembers())
+ {
+ if (member is INamespaceSymbol childNs)
+ {
+ FindContractImplementations(
+ childNs,
+ contractInterfaceFqns,
+ moduleName,
+ compilation,
+ results
+ );
+ }
+ else if (member is INamedTypeSymbol typeSymbol && typeSymbol.TypeKind == TypeKind.Class)
+ {
+ foreach (var iface in typeSymbol.AllInterfaces)
+ {
+ var ifaceFqn = iface.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ if (contractInterfaceFqns.Contains(ifaceFqn))
+ {
+ results.Add(
+ new ContractImplementationInfo
+ {
+ InterfaceFqn = ifaceFqn,
+ ImplementationFqn = typeSymbol.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ ),
+ ModuleName = moduleName,
+ IsPublic = typeSymbol.DeclaredAccessibility == Accessibility.Public,
+ IsAbstract = typeSymbol.IsAbstract,
+ DependsOnDbContext = DbContextFinder.HasDbContextConstructorParam(
+ typeSymbol
+ ),
+ Location = SymbolHelpers.GetSourceLocation(typeSymbol),
+ Lifetime = GetContractLifetime(typeSymbol),
+ }
+ );
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Reads [ContractLifetime(ServiceLifetime.X)] from the type.
+ /// Returns 1 (Scoped) if the attribute is not present.
+ /// ServiceLifetime: Singleton=0, Scoped=1, Transient=2
+ ///
+ internal static int GetContractLifetime(INamedTypeSymbol typeSymbol)
+ {
+ foreach (var attr in typeSymbol.GetAttributes())
+ {
+ var attrName = attr.AttributeClass?.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ );
+ if (
+ attrName == "global::SimpleModule.Core.ContractLifetimeAttribute"
+ && attr.ConstructorArguments.Length > 0
+ && attr.ConstructorArguments[0].Value is int lifetime
+ )
+ {
+ return lifetime;
+ }
+ }
+ return 1; // Default: Scoped
+ }
+
+ ///
+ /// Maps every pre-classified *.Contracts assembly to the module it belongs to.
+ /// Populates (name → module name) and
+ /// (name → IAssemblySymbol) for
+ /// downstream scans.
+ ///
+ internal static void BuildContractsAssemblyMap(
+ IReadOnlyList contractsAssemblies,
+ Dictionary moduleAssemblyMap,
+ Dictionary contractsAssemblyMap,
+ Dictionary contractsAssemblySymbols
+ )
+ {
+ foreach (var asm in contractsAssemblies)
+ {
+ var asmName = asm.Name;
+ var baseName = asmName.Substring(0, asmName.Length - ".Contracts".Length);
+
+ // Try exact match on assembly name
+ if (moduleAssemblyMap.TryGetValue(baseName, out var moduleName))
+ {
+ contractsAssemblyMap[asmName] = moduleName;
+ contractsAssemblySymbols[asmName] = asm;
+ continue;
+ }
+
+ // Try matching last segment of baseName to module names (case-insensitive)
+ var lastDot = baseName.LastIndexOf('.');
+ var lastSegment = lastDot >= 0 ? baseName.Substring(lastDot + 1) : baseName;
+
+ foreach (var kvp in moduleAssemblyMap)
+ {
+ if (string.Equals(lastSegment, kvp.Value, StringComparison.OrdinalIgnoreCase))
+ {
+ contractsAssemblyMap[asmName] = kvp.Value;
+ contractsAssemblySymbols[asmName] = asm;
+ break;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Walks every contracts assembly and records its public interfaces; then walks
+ /// each module's own assembly and records classes that implement any of those
+ /// interfaces. Populates and
+ /// .
+ ///
+ internal static void DiscoverInterfacesAndImplementations(
+ List modules,
+ Dictionary moduleSymbols,
+ Dictionary contractsAssemblySymbols,
+ Compilation compilation,
+ List contractInterfaces,
+ List contractImplementations
+ )
+ {
+ // Step 3: Scan contract interfaces
+ foreach (var kvp in contractsAssemblySymbols)
+ {
+ ScanContractInterfaces(kvp.Value.GlobalNamespace, kvp.Key, contractInterfaces);
+ }
+
+ // Step 3b: Find implementations of contract interfaces in module assemblies
+ foreach (var module in modules)
+ {
+ if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol))
+ continue;
+
+ var moduleAssembly = typeSymbol.ContainingAssembly;
+
+ // Find which contracts assembly this module owns
+ var expectedContractsAsm = moduleAssembly.Name + ".Contracts";
+ if (!contractsAssemblySymbols.ContainsKey(expectedContractsAsm))
+ continue;
+
+ // Get the interface FQNs from this module's contracts
+ var moduleContractInterfaceFqns = new HashSet();
+ foreach (var ci in contractInterfaces)
+ {
+ if (ci.ContractsAssemblyName == expectedContractsAsm)
+ moduleContractInterfaceFqns.Add(ci.InterfaceName);
+ }
+ if (moduleContractInterfaceFqns.Count == 0)
+ continue;
+
+ // Scan module assembly for implementations
+ FindContractImplementations(
+ moduleAssembly.GlobalNamespace,
+ moduleContractInterfaceFqns,
+ module.ModuleName,
+ compilation,
+ contractImplementations
+ );
+ }
+ }
+}
diff --git a/framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs
new file mode 100644
index 00000000..75ef7e0f
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs
@@ -0,0 +1,267 @@
+using System.Collections.Generic;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class DbContextFinder
+{
+ internal static void FindDbContextTypes(
+ INamespaceSymbol namespaceSymbol,
+ string moduleName,
+ List dbContexts,
+ CancellationToken cancellationToken
+ )
+ {
+ foreach (var member in namespaceSymbol.GetMembers())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (member is INamespaceSymbol childNamespace)
+ {
+ FindDbContextTypes(childNamespace, moduleName, dbContexts, cancellationToken);
+ }
+ else if (
+ member is INamedTypeSymbol typeSymbol
+ && !typeSymbol.IsAbstract
+ && !typeSymbol.IsStatic
+ )
+ {
+ // Walk base type chain looking for DbContext
+ var isDbContext = false;
+ var isIdentity = false;
+ string identityUserFqn = "";
+ string identityRoleFqn = "";
+ string identityKeyFqn = "";
+
+ var current = typeSymbol.BaseType;
+ while (current is not null)
+ {
+ var baseFqn = current.OriginalDefinition.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ );
+
+ if (
+ baseFqn
+ == "global::Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext"
+ )
+ {
+ isDbContext = true;
+ isIdentity = true;
+ if (current.TypeArguments.Length >= 3)
+ {
+ identityUserFqn = current
+ .TypeArguments[0]
+ .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ identityRoleFqn = current
+ .TypeArguments[1]
+ .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ identityKeyFqn = current
+ .TypeArguments[2]
+ .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ }
+ break;
+ }
+
+ if (baseFqn == "global::Microsoft.EntityFrameworkCore.DbContext")
+ {
+ isDbContext = true;
+ break;
+ }
+
+ current = current.BaseType;
+ }
+
+ if (!isDbContext)
+ continue;
+
+ var info = new DbContextInfo
+ {
+ FullyQualifiedName = typeSymbol.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ ),
+ ModuleName = moduleName,
+ IsIdentityDbContext = isIdentity,
+ IdentityUserTypeFqn = identityUserFqn,
+ IdentityRoleTypeFqn = identityRoleFqn,
+ IdentityKeyTypeFqn = identityKeyFqn,
+ Location = SymbolHelpers.GetSourceLocation(typeSymbol),
+ };
+
+ // Collect DbSet properties
+ foreach (var m in typeSymbol.GetMembers())
+ {
+ if (
+ m is IPropertySymbol prop
+ && prop.DeclaredAccessibility == Accessibility.Public
+ && !prop.IsStatic
+ && prop.Type is INamedTypeSymbol propType
+ && propType.IsGenericType
+ && propType.OriginalDefinition.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ ) == "global::Microsoft.EntityFrameworkCore.DbSet"
+ )
+ {
+ var entityType = propType.TypeArguments[0];
+ var entityFqn = entityType.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ );
+ var entityAssemblyName =
+ entityType.ContainingAssembly?.Name ?? string.Empty;
+ info.DbSets.Add(
+ new DbSetInfo
+ {
+ PropertyName = prop.Name,
+ EntityFqn = entityFqn,
+ EntityAssemblyName = entityAssemblyName,
+ EntityLocation = SymbolHelpers.GetSourceLocation(entityType),
+ }
+ );
+ }
+ }
+
+ dbContexts.Add(info);
+ }
+ }
+ }
+
+ internal static void FindEntityConfigTypes(
+ INamespaceSymbol namespaceSymbol,
+ string moduleName,
+ List entityConfigs,
+ CancellationToken cancellationToken
+ )
+ {
+ foreach (var member in namespaceSymbol.GetMembers())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (member is INamespaceSymbol childNamespace)
+ {
+ FindEntityConfigTypes(childNamespace, moduleName, entityConfigs, cancellationToken);
+ }
+ else if (
+ member is INamedTypeSymbol typeSymbol
+ && !typeSymbol.IsAbstract
+ && !typeSymbol.IsStatic
+ )
+ {
+ foreach (var iface in typeSymbol.AllInterfaces)
+ {
+ if (
+ iface.IsGenericType
+ && iface.OriginalDefinition.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ )
+ == "global::Microsoft.EntityFrameworkCore.IEntityTypeConfiguration"
+ )
+ {
+ var entityFqn = iface
+ .TypeArguments[0]
+ .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ entityConfigs.Add(
+ new EntityConfigInfo
+ {
+ ConfigFqn = typeSymbol.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ ),
+ EntityFqn = entityFqn,
+ ModuleName = moduleName,
+ Location = SymbolHelpers.GetSourceLocation(typeSymbol),
+ }
+ );
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// For each module, scans the module's own assembly (once per unique
+ /// assembly) and distributes every discovered DbContext / EntityTypeConfiguration
+ /// to the module whose namespace is closest.
+ ///
+ internal static void Discover(
+ List modules,
+ Dictionary moduleSymbols,
+ SymbolHelpers.ModuleNamespaceIndex moduleNsIndex,
+ List dbContexts,
+ List entityConfigs,
+ CancellationToken cancellationToken
+ )
+ {
+ var scannedAssemblies = new HashSet(SymbolEqualityComparer.Default);
+ foreach (var module in modules)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol))
+ continue;
+
+ var assembly = typeSymbol.ContainingAssembly;
+ if (!scannedAssemblies.Add(assembly))
+ continue;
+
+ // Collect unmatched items from this assembly
+ var rawDbContexts = new List();
+ var rawEntityConfigs = new List();
+ FindDbContextTypes(assembly.GlobalNamespace, "", rawDbContexts, cancellationToken);
+ FindEntityConfigTypes(
+ assembly.GlobalNamespace,
+ "",
+ rawEntityConfigs,
+ cancellationToken
+ );
+
+ // Match each DbContext to the module whose namespace is closest
+ foreach (var ctx in rawDbContexts)
+ {
+ var ctxNs = TypeMappingHelpers.StripGlobalPrefix(ctx.FullyQualifiedName);
+ ctx.ModuleName = SymbolHelpers.FindClosestModuleNameFast(ctxNs, moduleNsIndex);
+ dbContexts.Add(ctx);
+ }
+
+ foreach (var cfg in rawEntityConfigs)
+ {
+ var cfgNs = TypeMappingHelpers.StripGlobalPrefix(cfg.ConfigFqn);
+ cfg.ModuleName = SymbolHelpers.FindClosestModuleNameFast(cfgNs, moduleNsIndex);
+ entityConfigs.Add(cfg);
+ }
+ }
+ }
+
+ internal static bool HasDbContextConstructorParam(INamedTypeSymbol typeSymbol)
+ {
+ foreach (var ctor in typeSymbol.Constructors)
+ {
+ if (ctor.DeclaredAccessibility != Accessibility.Public || ctor.IsStatic)
+ continue;
+
+ foreach (var param in ctor.Parameters)
+ {
+ var paramType = param.Type;
+ // Walk the base type chain to check for DbContext ancestry
+ var current = paramType.BaseType;
+ while (current != null)
+ {
+ var baseFqn = current.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ if (
+ baseFqn == "global::Microsoft.EntityFrameworkCore.DbContext"
+ || baseFqn.StartsWith(
+ "global::Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext",
+ StringComparison.Ordinal
+ )
+ )
+ {
+ return true;
+ }
+
+ current = current.BaseType;
+ }
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs
new file mode 100644
index 00000000..d03e6fb6
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs
@@ -0,0 +1,240 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class DtoFinder
+{
+ internal static void FindDtoTypes(
+ INamespaceSymbol namespaceSymbol,
+ INamedTypeSymbol dtoAttributeSymbol,
+ List dtoTypes,
+ CancellationToken cancellationToken
+ )
+ {
+ foreach (var member in namespaceSymbol.GetMembers())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (member is INamespaceSymbol childNamespace)
+ {
+ FindDtoTypes(childNamespace, dtoAttributeSymbol, dtoTypes, cancellationToken);
+ }
+ else if (member is INamedTypeSymbol typeSymbol)
+ {
+ foreach (var attr in typeSymbol.GetAttributes())
+ {
+ if (
+ SymbolEqualityComparer.Default.Equals(
+ attr.AttributeClass,
+ dtoAttributeSymbol
+ )
+ )
+ {
+ var fqn = typeSymbol.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ );
+ var safeName = TypeMappingHelpers.StripGlobalPrefix(fqn).Replace(".", "_");
+
+ string? baseTypeFqn = null;
+ if (
+ typeSymbol.BaseType
+ is { SpecialType: not SpecialType.System_Object }
+ and var baseType
+ )
+ {
+ var baseFqn = baseType.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ );
+ if (
+ baseType
+ .GetAttributes()
+ .Any(a =>
+ SymbolEqualityComparer.Default.Equals(
+ a.AttributeClass,
+ dtoAttributeSymbol
+ )
+ )
+ )
+ {
+ baseTypeFqn = baseFqn;
+ }
+ }
+
+ dtoTypes.Add(
+ new DtoTypeInfo
+ {
+ FullyQualifiedName = fqn,
+ SafeName = safeName,
+ BaseTypeFqn = baseTypeFqn,
+ Properties = DtoPropertyExtractor.Extract(typeSymbol),
+ }
+ );
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ internal static void FindConventionDtoTypes(
+ INamespaceSymbol namespaceSymbol,
+ INamedTypeSymbol? noDtoAttrSymbol,
+ INamedTypeSymbol? eventInterfaceSymbol,
+ HashSet existingFqns,
+ List dtoTypes,
+ CancellationToken cancellationToken
+ )
+ {
+ foreach (var member in namespaceSymbol.GetMembers())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (member is INamespaceSymbol childNs)
+ {
+ // Skip walking into System.*, Microsoft.*, or Vogen.* trees — they never contain
+ // convention DTOs and traversing them adds zero value while inflating symbol-tree walks.
+ var childName = childNs.Name;
+ if (childName == "System" || childName == "Microsoft" || childName == "Vogen")
+ continue;
+
+ FindConventionDtoTypes(
+ childNs,
+ noDtoAttrSymbol,
+ eventInterfaceSymbol,
+ existingFqns,
+ dtoTypes,
+ cancellationToken
+ );
+ }
+ else if (
+ member is INamedTypeSymbol typeSymbol
+ && typeSymbol.DeclaredAccessibility == Accessibility.Public
+ && !typeSymbol.IsStatic
+ && typeSymbol.TypeKind != TypeKind.Interface
+ && typeSymbol.TypeKind != TypeKind.Enum
+ && typeSymbol.TypeKind != TypeKind.Delegate
+ )
+ {
+ var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ // Skip if already found via [Dto]
+ if (existingFqns.Contains(fqn))
+ continue;
+
+ // Skip if [NoDtoGeneration]
+ if (noDtoAttrSymbol is not null)
+ {
+ var hasNoDtoAttr = false;
+ foreach (var attr in typeSymbol.GetAttributes())
+ {
+ if (
+ SymbolEqualityComparer.Default.Equals(
+ attr.AttributeClass,
+ noDtoAttrSymbol
+ )
+ )
+ {
+ hasNoDtoAttr = true;
+ break;
+ }
+ }
+ if (hasNoDtoAttr)
+ continue;
+ }
+
+ // Skip types that implement IEvent (events are not DTOs)
+ if (eventInterfaceSymbol is not null)
+ {
+ var isEvent = false;
+ foreach (var iface in typeSymbol.AllInterfaces)
+ {
+ if (SymbolEqualityComparer.Default.Equals(iface, eventInterfaceSymbol))
+ {
+ isEvent = true;
+ break;
+ }
+ }
+ if (isEvent)
+ continue;
+ }
+
+ // Skip generic type definitions (open generics like PagedResult)
+ if (typeSymbol.IsGenericType)
+ continue;
+
+ // Skip Vogen-generated infrastructure types
+ if (
+ typeSymbol.Name == "VogenTypesFactory"
+ || fqn.StartsWith("global::Vogen", StringComparison.Ordinal)
+ )
+ continue;
+
+ // Skip Vogen value objects — they have their own JsonConverter
+ // and must not be treated as regular DTOs in the JSON resolver
+ if (VogenFinder.IsVogenValueObject(typeSymbol))
+ continue;
+
+ var safeName = TypeMappingHelpers.StripGlobalPrefix(fqn).Replace(".", "_");
+
+ string? baseTypeFqn = null;
+ if (
+ typeSymbol.BaseType
+ is { SpecialType: not SpecialType.System_Object }
+ and var baseType
+ )
+ {
+ var baseFqn = baseType.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ );
+ if (existingFqns.Contains(baseFqn))
+ {
+ baseTypeFqn = baseFqn;
+ }
+ }
+
+ existingFqns.Add(fqn);
+ dtoTypes.Add(
+ new DtoTypeInfo
+ {
+ FullyQualifiedName = fqn,
+ SafeName = safeName,
+ BaseTypeFqn = baseTypeFqn,
+ Properties = DtoPropertyExtractor.Extract(typeSymbol),
+ }
+ );
+ }
+ }
+ }
+
+ ///
+ /// Scans every referenced assembly AND the host assembly for types decorated
+ /// with [Dto]. No-op when the DtoAttribute symbol isn't resolvable.
+ ///
+ internal static void DiscoverAttributedDtos(
+ IReadOnlyList refAssemblies,
+ INamespaceSymbol hostGlobalNamespace,
+ CoreSymbols symbols,
+ List dtoTypes,
+ CancellationToken cancellationToken
+ )
+ {
+ if (symbols.DtoAttribute is null)
+ return;
+
+ foreach (var assemblySymbol in refAssemblies)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ FindDtoTypes(
+ assemblySymbol.GlobalNamespace,
+ symbols.DtoAttribute,
+ dtoTypes,
+ cancellationToken
+ );
+ }
+
+ FindDtoTypes(hostGlobalNamespace, symbols.DtoAttribute, dtoTypes, cancellationToken);
+ }
+}
diff --git a/framework/SimpleModule.Generator/Discovery/Finders/DtoPropertyExtractor.cs b/framework/SimpleModule.Generator/Discovery/Finders/DtoPropertyExtractor.cs
new file mode 100644
index 00000000..e20ae4f3
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/Finders/DtoPropertyExtractor.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class DtoPropertyExtractor
+{
+ ///
+ /// Walks the inheritance chain (most-derived first) so derived properties shadow
+ /// base properties of the same name. This lets DTOs inherit shared base classes
+ /// (e.g., AuditableEntity{TId} → Id, CreatedAt, UpdatedAt, ConcurrencyStamp)
+ /// and still get serialized correctly.
+ ///
+ internal static List Extract(INamedTypeSymbol typeSymbol)
+ {
+ var seen = new HashSet(StringComparer.Ordinal);
+ var properties = new List();
+ for (
+ var current = typeSymbol;
+ current is not null && current.SpecialType != SpecialType.System_Object;
+ current = current.BaseType
+ )
+ {
+ foreach (var m in current.GetMembers())
+ {
+ if (
+ m is IPropertySymbol prop
+ && prop.DeclaredAccessibility == Accessibility.Public
+ && !prop.IsStatic
+ && !prop.IsIndexer
+ && prop.GetMethod is not null
+ && !HasJsonIgnoreAttribute(prop)
+ && seen.Add(prop.Name)
+ )
+ {
+ var resolvedType = VogenFinder.ResolveUnderlyingType(prop.Type);
+ var actualType = prop.Type.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ );
+ properties.Add(
+ new DtoPropertyInfo
+ {
+ Name = prop.Name,
+ TypeFqn = actualType,
+ UnderlyingTypeFqn = resolvedType != actualType ? resolvedType : null,
+ HasSetter =
+ prop.SetMethod is not null
+ && prop.SetMethod.DeclaredAccessibility == Accessibility.Public,
+ }
+ );
+ }
+ }
+ }
+ return properties;
+ }
+
+ ///
+ /// Returns true if the property is decorated with [System.Text.Json.Serialization.JsonIgnore].
+ /// Properties marked this way are excluded from generated JSON metadata, mirroring
+ /// runtime System.Text.Json behavior.
+ ///
+ private static bool HasJsonIgnoreAttribute(IPropertySymbol prop)
+ {
+ foreach (var attr in prop.GetAttributes())
+ {
+ var name = attr.AttributeClass?.ToDisplayString();
+ if (name == "System.Text.Json.Serialization.JsonIgnoreAttribute")
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs
new file mode 100644
index 00000000..96f1f9d2
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs
@@ -0,0 +1,262 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class EndpointFinder
+{
+ internal static void FindEndpointTypes(
+ INamespaceSymbol namespaceSymbol,
+ CoreSymbols symbols,
+ List endpoints,
+ List views,
+ CancellationToken cancellationToken
+ )
+ {
+ if (symbols.EndpointInterface is null)
+ return;
+
+ FindEndpointTypesInternal(
+ namespaceSymbol,
+ symbols.EndpointInterface,
+ symbols.ViewEndpointInterface,
+ endpoints,
+ views,
+ cancellationToken
+ );
+ }
+
+ private static void FindEndpointTypesInternal(
+ INamespaceSymbol namespaceSymbol,
+ INamedTypeSymbol endpointInterfaceSymbol,
+ INamedTypeSymbol? viewEndpointInterfaceSymbol,
+ List endpoints,
+ List views,
+ CancellationToken cancellationToken
+ )
+ {
+ foreach (var member in namespaceSymbol.GetMembers())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (member is INamespaceSymbol childNamespace)
+ {
+ FindEndpointTypesInternal(
+ childNamespace,
+ endpointInterfaceSymbol,
+ viewEndpointInterfaceSymbol,
+ endpoints,
+ views,
+ cancellationToken
+ );
+ }
+ else if (member is INamedTypeSymbol typeSymbol)
+ {
+ if (!typeSymbol.IsAbstract && !typeSymbol.IsStatic)
+ {
+ var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ if (
+ viewEndpointInterfaceSymbol is not null
+ && SymbolHelpers.ImplementsInterface(
+ typeSymbol,
+ viewEndpointInterfaceSymbol
+ )
+ )
+ {
+ var className = typeSymbol.Name;
+ if (className.EndsWith("Endpoint", StringComparison.Ordinal))
+ className = className.Substring(
+ 0,
+ className.Length - "Endpoint".Length
+ );
+ else if (className.EndsWith("View", StringComparison.Ordinal))
+ className = className.Substring(0, className.Length - "View".Length);
+
+ var viewInfo = new ViewInfo
+ {
+ FullyQualifiedName = fqn,
+ InferredClassName = className,
+ Location = SymbolHelpers.GetSourceLocation(typeSymbol),
+ };
+
+ var (viewRoute, _) = ReadRouteConstFields(typeSymbol);
+ viewInfo.RouteTemplate = viewRoute;
+ views.Add(viewInfo);
+ }
+ else if (SymbolHelpers.ImplementsInterface(typeSymbol, endpointInterfaceSymbol))
+ {
+ var info = new EndpointInfo { FullyQualifiedName = fqn };
+
+ foreach (var attr in typeSymbol.GetAttributes())
+ {
+ var attrName = attr.AttributeClass?.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ );
+
+ if (
+ attrName
+ == "global::SimpleModule.Core.Authorization.RequirePermissionAttribute"
+ )
+ {
+ if (attr.ConstructorArguments.Length > 0)
+ {
+ var arg = attr.ConstructorArguments[0];
+ if (arg.Kind == TypedConstantKind.Array)
+ {
+ foreach (var val in arg.Values)
+ {
+ if (val.Value is string s)
+ info.RequiredPermissions.Add(s);
+ }
+ }
+ else if (arg.Value is string single)
+ {
+ info.RequiredPermissions.Add(single);
+ }
+ }
+ }
+ else if (
+ attrName
+ == "global::Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute"
+ )
+ {
+ info.AllowAnonymous = true;
+ }
+ }
+
+ var (epRoute, epMethod) = ReadRouteConstFields(typeSymbol);
+ info.RouteTemplate = epRoute;
+ info.HttpMethod = epMethod;
+ endpoints.Add(info);
+ }
+ }
+ }
+ }
+ }
+
+ private static (string route, string method) ReadRouteConstFields(INamedTypeSymbol typeSymbol)
+ {
+ var route = "";
+ var method = "";
+ foreach (var m in typeSymbol.GetMembers())
+ {
+ if (m is IFieldSymbol { IsConst: true, ConstantValue: string value } field)
+ {
+ if (field.Name == "Route")
+ route = value;
+ else if (field.Name == "Method")
+ method = value;
+ }
+ }
+ return (route, method);
+ }
+
+ ///
+ /// For each module, scans the module's own implementation assembly (once per
+ /// unique assembly) and distributes every discovered endpoint/view to the
+ /// module whose namespace is closest. Views also get their page name inferred
+ /// from namespace segments (e.g. SimpleModule.Users.Pages.Account.LoginEndpoint
+ /// becomes Users/Account/Login).
+ ///
+ internal static void Discover(
+ List modules,
+ Dictionary moduleSymbols,
+ Dictionary modulesByName,
+ SymbolHelpers.ModuleNamespaceIndex moduleNsIndex,
+ CoreSymbols symbols,
+ CancellationToken cancellationToken
+ )
+ {
+ var endpointScannedAssemblies = new HashSet(
+ SymbolEqualityComparer.Default
+ );
+
+ // Pre-compute module namespace per module name for page inference (built once, outside per-module loop)
+ var moduleNsByName = new Dictionary();
+ foreach (var m in modules)
+ {
+ if (!moduleNsByName.ContainsKey(m.ModuleName))
+ {
+ var mFqn = TypeMappingHelpers.StripGlobalPrefix(m.FullyQualifiedName);
+ moduleNsByName[m.ModuleName] = TypeMappingHelpers.ExtractNamespace(mFqn);
+ }
+ }
+
+ foreach (var module in modules)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol))
+ continue;
+
+ var assembly = typeSymbol.ContainingAssembly;
+ if (!endpointScannedAssemblies.Add(assembly))
+ continue;
+
+ var rawEndpoints = new List();
+ var rawViews = new List();
+ FindEndpointTypes(
+ assembly.GlobalNamespace,
+ symbols,
+ rawEndpoints,
+ rawViews,
+ cancellationToken
+ );
+
+ // Match each endpoint/view to the module whose namespace is closest
+ foreach (var ep in rawEndpoints)
+ {
+ var epFqn = TypeMappingHelpers.StripGlobalPrefix(ep.FullyQualifiedName);
+ var ownerName = SymbolHelpers.FindClosestModuleNameFast(epFqn, moduleNsIndex);
+ modulesByName.TryGetValue(ownerName, out var owner);
+ if (owner is not null)
+ owner.Endpoints.Add(ep);
+ }
+
+ foreach (var v in rawViews)
+ {
+ var vFqn = TypeMappingHelpers.StripGlobalPrefix(v.FullyQualifiedName);
+ var ownerName = SymbolHelpers.FindClosestModuleNameFast(vFqn, moduleNsIndex);
+ modulesByName.TryGetValue(ownerName, out var owner);
+ if (owner is not null)
+ {
+ // Derive page name from namespace segments between module NS and class name.
+ // e.g. SimpleModule.Users.Pages.Account.LoginEndpoint → Users/Account/Login
+ if (v.Page is null)
+ {
+ var moduleNs = moduleNsByName[ownerName];
+ var typeNs = TypeMappingHelpers.ExtractNamespace(vFqn);
+
+ // Extract segments after the module namespace, stripping Views/Pages
+ var remaining =
+ typeNs.Length > moduleNs.Length
+ ? typeNs.Substring(moduleNs.Length).TrimStart('.')
+ : "";
+
+ var segments = remaining.Split('.');
+ var pathParts = new List();
+ foreach (var seg in segments)
+ {
+ if (
+ seg.Length > 0
+ && !seg.Equals("Views", StringComparison.Ordinal)
+ && !seg.Equals("Pages", StringComparison.Ordinal)
+ )
+ {
+ pathParts.Add(seg);
+ }
+ }
+
+ var subPath = pathParts.Count > 0 ? string.Join("/", pathParts) + "/" : "";
+ v.Page = ownerName + "/" + subPath + v.InferredClassName;
+ }
+
+ owner.Views.Add(v);
+ }
+ }
+ }
+ }
+}
diff --git a/framework/SimpleModule.Generator/Discovery/Finders/InterceptorFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/InterceptorFinder.cs
new file mode 100644
index 00000000..4c5230ca
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/Finders/InterceptorFinder.cs
@@ -0,0 +1,88 @@
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class InterceptorFinder
+{
+ internal static void FindInterceptorTypes(
+ INamespaceSymbol namespaceSymbol,
+ INamedTypeSymbol saveChangesInterceptorSymbol,
+ string moduleName,
+ List results
+ )
+ {
+ foreach (var member in namespaceSymbol.GetMembers())
+ {
+ if (member is INamespaceSymbol childNs)
+ {
+ FindInterceptorTypes(childNs, saveChangesInterceptorSymbol, moduleName, results);
+ }
+ else if (
+ member is INamedTypeSymbol typeSymbol
+ && typeSymbol.TypeKind == TypeKind.Class
+ && !typeSymbol.IsAbstract
+ && !typeSymbol.IsStatic
+ && SymbolHelpers.ImplementsInterface(typeSymbol, saveChangesInterceptorSymbol)
+ )
+ {
+ var info = new InterceptorInfo
+ {
+ FullyQualifiedName = typeSymbol.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ ),
+ ModuleName = moduleName,
+ Location = SymbolHelpers.GetSourceLocation(typeSymbol),
+ };
+
+ // Extract constructor parameter type FQNs
+ foreach (var ctor in typeSymbol.Constructors)
+ {
+ if (ctor.DeclaredAccessibility != Accessibility.Public)
+ continue;
+
+ foreach (var param in ctor.Parameters)
+ {
+ info.ConstructorParamTypeFqns.Add(
+ param.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
+ );
+ }
+
+ // Only process the first public constructor
+ break;
+ }
+
+ results.Add(info);
+ }
+ }
+ }
+
+ ///
+ /// Scans every module's implementation assembly for ISaveChangesInterceptor
+ /// implementors. No-op when SaveChangesInterceptor isn't resolvable.
+ ///
+ internal static void Discover(
+ List modules,
+ Dictionary moduleSymbols,
+ CoreSymbols symbols,
+ List interceptors
+ )
+ {
+ if (symbols.SaveChangesInterceptor is not null)
+ {
+ SymbolHelpers.ScanModuleAssemblies(
+ modules,
+ moduleSymbols,
+ (assembly, module) =>
+ {
+ FindInterceptorTypes(
+ assembly.GlobalNamespace,
+ symbols.SaveChangesInterceptor,
+ module.ModuleName,
+ interceptors
+ );
+ }
+ );
+ }
+ }
+}
diff --git a/framework/SimpleModule.Generator/Discovery/Finders/ModuleFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/ModuleFinder.cs
new file mode 100644
index 00000000..82d93654
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/Finders/ModuleFinder.cs
@@ -0,0 +1,135 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class ModuleFinder
+{
+ internal static void FindModuleTypes(
+ INamespaceSymbol namespaceSymbol,
+ CoreSymbols symbols,
+ List modules,
+ CancellationToken cancellationToken
+ )
+ {
+ foreach (var member in namespaceSymbol.GetMembers())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (member is INamespaceSymbol childNamespace)
+ {
+ FindModuleTypes(childNamespace, symbols, modules, cancellationToken);
+ }
+ else if (member is INamedTypeSymbol typeSymbol)
+ {
+ foreach (var attr in typeSymbol.GetAttributes())
+ {
+ if (
+ SymbolEqualityComparer.Default.Equals(
+ attr.AttributeClass,
+ symbols.ModuleAttribute
+ )
+ )
+ {
+ var moduleName =
+ attr.ConstructorArguments.Length > 0
+ ? attr.ConstructorArguments[0].Value as string ?? ""
+ : "";
+ var routePrefix = "";
+ var viewPrefix = "";
+ foreach (var namedArg in attr.NamedArguments)
+ {
+ if (
+ namedArg.Key == "RoutePrefix"
+ && namedArg.Value.Value is string prefix
+ )
+ {
+ routePrefix = prefix;
+ }
+ else if (
+ namedArg.Key == "ViewPrefix"
+ && namedArg.Value.Value is string vPrefix
+ )
+ {
+ viewPrefix = vPrefix;
+ }
+ }
+
+ modules.Add(
+ new ModuleInfo
+ {
+ FullyQualifiedName = typeSymbol.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ ),
+ ModuleName = moduleName,
+ HasConfigureServices =
+ SymbolHelpers.DeclaresMethod(typeSymbol, "ConfigureServices")
+ || (
+ symbols.ModuleServices is not null
+ && SymbolHelpers.ImplementsInterface(
+ typeSymbol,
+ symbols.ModuleServices
+ )
+ ),
+ HasConfigureEndpoints = SymbolHelpers.DeclaresMethod(
+ typeSymbol,
+ "ConfigureEndpoints"
+ ),
+ HasConfigureMenu =
+ SymbolHelpers.DeclaresMethod(typeSymbol, "ConfigureMenu")
+ || (
+ symbols.ModuleMenu is not null
+ && SymbolHelpers.ImplementsInterface(
+ typeSymbol,
+ symbols.ModuleMenu
+ )
+ ),
+ HasConfigureMiddleware =
+ SymbolHelpers.DeclaresMethod(typeSymbol, "ConfigureMiddleware")
+ || (
+ symbols.ModuleMiddleware is not null
+ && SymbolHelpers.ImplementsInterface(
+ typeSymbol,
+ symbols.ModuleMiddleware
+ )
+ ),
+ HasConfigurePermissions = SymbolHelpers.DeclaresMethod(
+ typeSymbol,
+ "ConfigurePermissions"
+ ),
+ HasConfigureSettings =
+ SymbolHelpers.DeclaresMethod(typeSymbol, "ConfigureSettings")
+ || (
+ symbols.ModuleSettings is not null
+ && SymbolHelpers.ImplementsInterface(
+ typeSymbol,
+ symbols.ModuleSettings
+ )
+ ),
+ HasConfigureFeatureFlags = SymbolHelpers.DeclaresMethod(
+ typeSymbol,
+ "ConfigureFeatureFlags"
+ ),
+ HasConfigureAgents = SymbolHelpers.DeclaresMethod(
+ typeSymbol,
+ "ConfigureAgents"
+ ),
+ HasConfigureRateLimits = SymbolHelpers.DeclaresMethod(
+ typeSymbol,
+ "ConfigureRateLimits"
+ ),
+ RoutePrefix = routePrefix,
+ ViewPrefix = viewPrefix,
+ AssemblyName = typeSymbol.ContainingAssembly.Name,
+ Location = SymbolHelpers.GetSourceLocation(typeSymbol),
+ }
+ );
+ break;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/framework/SimpleModule.Generator/Discovery/Finders/PermissionFeatureFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/PermissionFeatureFinder.cs
new file mode 100644
index 00000000..a3d574a7
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/Finders/PermissionFeatureFinder.cs
@@ -0,0 +1,282 @@
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class PermissionFeatureFinder
+{
+ internal static void FindPermissionClasses(
+ INamespaceSymbol namespaceSymbol,
+ INamedTypeSymbol modulePermissionsSymbol,
+ string moduleName,
+ List results
+ )
+ {
+ foreach (var member in namespaceSymbol.GetMembers())
+ {
+ if (member is INamespaceSymbol childNs)
+ {
+ FindPermissionClasses(childNs, modulePermissionsSymbol, moduleName, results);
+ }
+ else if (
+ member is INamedTypeSymbol typeSymbol
+ && typeSymbol.TypeKind == TypeKind.Class
+ && SymbolHelpers.ImplementsInterface(typeSymbol, modulePermissionsSymbol)
+ )
+ {
+ var info = new PermissionClassInfo
+ {
+ FullyQualifiedName = typeSymbol.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ ),
+ ModuleName = moduleName,
+ IsSealed = typeSymbol.IsSealed,
+ Location = SymbolHelpers.GetSourceLocation(typeSymbol),
+ };
+
+ // Collect public const string fields
+ foreach (var m in typeSymbol.GetMembers())
+ {
+ if (
+ m is IFieldSymbol field
+ && field.DeclaredAccessibility == Accessibility.Public
+ )
+ {
+ info.Fields.Add(
+ new PermissionFieldInfo
+ {
+ FieldName = field.Name,
+ Value =
+ field.HasConstantValue && field.ConstantValue is string s
+ ? s
+ : "",
+ IsConstString =
+ field.IsConst
+ && field.Type.SpecialType == SpecialType.System_String,
+ Location = SymbolHelpers.GetSourceLocation(field),
+ }
+ );
+ }
+ }
+
+ results.Add(info);
+ }
+ }
+ }
+
+ internal static void FindFeatureClasses(
+ INamespaceSymbol namespaceSymbol,
+ INamedTypeSymbol moduleFeaturesSymbol,
+ string moduleName,
+ List results
+ )
+ {
+ foreach (var member in namespaceSymbol.GetMembers())
+ {
+ if (member is INamespaceSymbol childNs)
+ {
+ FindFeatureClasses(childNs, moduleFeaturesSymbol, moduleName, results);
+ }
+ else if (
+ member is INamedTypeSymbol typeSymbol
+ && typeSymbol.TypeKind == TypeKind.Class
+ && SymbolHelpers.ImplementsInterface(typeSymbol, moduleFeaturesSymbol)
+ )
+ {
+ var info = new FeatureClassInfo
+ {
+ FullyQualifiedName = typeSymbol.ToDisplayString(
+ SymbolDisplayFormat.FullyQualifiedFormat
+ ),
+ ModuleName = moduleName,
+ IsSealed = typeSymbol.IsSealed,
+ Location = SymbolHelpers.GetSourceLocation(typeSymbol),
+ };
+
+ // Collect public const string fields
+ foreach (var m in typeSymbol.GetMembers())
+ {
+ if (
+ m is IFieldSymbol field
+ && field.DeclaredAccessibility == Accessibility.Public
+ )
+ {
+ info.Fields.Add(
+ new FeatureFieldInfo
+ {
+ FieldName = field.Name,
+ Value =
+ field.HasConstantValue && field.ConstantValue is string s
+ ? s
+ : "",
+ IsConstString =
+ field.IsConst
+ && field.Type.SpecialType == SpecialType.System_String,
+ Location = SymbolHelpers.GetSourceLocation(field),
+ }
+ );
+ }
+ }
+
+ results.Add(info);
+ }
+ }
+ }
+
+ internal static void FindModuleOptionsClasses(
+ INamespaceSymbol namespaceSymbol,
+ INamedTypeSymbol moduleOptionsSymbol,
+ string moduleName,
+ List results
+ )
+ {
+ SymbolHelpers.FindConcreteClassesImplementing(
+ namespaceSymbol,
+ moduleOptionsSymbol,
+ typeSymbol =>
+ results.Add(
+ new ModuleOptionsRecord(
+ typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
+ moduleName,
+ SymbolHelpers.GetSourceLocation(typeSymbol)
+ )
+ )
+ );
+ }
+
+ ///
+ /// Scans every module's implementation assembly AND every contracts assembly
+ /// for IModulePermissions implementors. No-op when ModulePermissions isn't
+ /// resolvable in the compilation.
+ ///
+ internal static void DiscoverPermissions(
+ List modules,
+ Dictionary moduleSymbols,
+ Dictionary contractsAssemblySymbols,
+ Dictionary contractsAssemblyMap,
+ CoreSymbols symbols,
+ List permissionClasses
+ )
+ {
+ if (symbols.ModulePermissions is not null)
+ {
+ foreach (var module in modules)
+ {
+ if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol))
+ continue;
+
+ var moduleAssembly = typeSymbol.ContainingAssembly;
+ FindPermissionClasses(
+ moduleAssembly.GlobalNamespace,
+ symbols.ModulePermissions,
+ module.ModuleName,
+ permissionClasses
+ );
+ }
+
+ // Also scan contracts assemblies for permission classes
+ foreach (var kvp in contractsAssemblySymbols)
+ {
+ if (contractsAssemblyMap.TryGetValue(kvp.Key, out var moduleName))
+ {
+ FindPermissionClasses(
+ kvp.Value.GlobalNamespace,
+ symbols.ModulePermissions,
+ moduleName,
+ permissionClasses
+ );
+ }
+ }
+ }
+ }
+
+ ///
+ /// Scans every module's implementation assembly AND every contracts assembly
+ /// for IModuleFeatures implementors. No-op when ModuleFeatures isn't
+ /// resolvable in the compilation.
+ ///
+ internal static void DiscoverFeatures(
+ List modules,
+ Dictionary moduleSymbols,
+ Dictionary contractsAssemblySymbols,
+ Dictionary contractsAssemblyMap,
+ CoreSymbols symbols,
+ List featureClasses
+ )
+ {
+ if (symbols.ModuleFeatures is not null)
+ {
+ foreach (var module in modules)
+ {
+ if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol))
+ continue;
+
+ var moduleAssembly = typeSymbol.ContainingAssembly;
+ FindFeatureClasses(
+ moduleAssembly.GlobalNamespace,
+ symbols.ModuleFeatures,
+ module.ModuleName,
+ featureClasses
+ );
+ }
+
+ // Also scan contracts assemblies for feature classes
+ foreach (var kvp in contractsAssemblySymbols)
+ {
+ if (contractsAssemblyMap.TryGetValue(kvp.Key, out var moduleName))
+ {
+ FindFeatureClasses(
+ kvp.Value.GlobalNamespace,
+ symbols.ModuleFeatures,
+ moduleName,
+ featureClasses
+ );
+ }
+ }
+ }
+ }
+
+ ///
+ /// Scans every module's implementation assembly AND every contracts assembly
+ /// for IModuleOptions implementors. No-op when ModuleOptions isn't
+ /// resolvable in the compilation.
+ ///
+ internal static void DiscoverModuleOptions(
+ List modules,
+ Dictionary moduleSymbols,
+ Dictionary contractsAssemblySymbols,
+ Dictionary contractsAssemblyMap,
+ CoreSymbols symbols,
+ List moduleOptionsList
+ )
+ {
+ if (symbols.ModuleOptions is not null)
+ {
+ SymbolHelpers.ScanModuleAssemblies(
+ modules,
+ moduleSymbols,
+ (assembly, module) =>
+ FindModuleOptionsClasses(
+ assembly.GlobalNamespace,
+ symbols.ModuleOptions,
+ 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,
+ symbols.ModuleOptions,
+ moduleName,
+ moduleOptionsList
+ );
+ }
+ }
+ }
+ }
+}
diff --git a/framework/SimpleModule.Generator/Discovery/Finders/VogenFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/VogenFinder.cs
new file mode 100644
index 00000000..1b30d17b
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/Finders/VogenFinder.cs
@@ -0,0 +1,124 @@
+using System.Collections.Generic;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class VogenFinder
+{
+ internal static void FindVogenValueObjectsWithEfConverters(
+ INamespaceSymbol ns,
+ List results
+ )
+ {
+ foreach (var type in ns.GetTypeMembers())
+ {
+ if (!IsVogenValueObject(type))
+ continue;
+
+ var converterMembers = type.GetTypeMembers("EfCoreValueConverter");
+ var comparerMembers = type.GetTypeMembers("EfCoreValueComparer");
+
+ if (converterMembers.Length == 0 || comparerMembers.Length == 0)
+ continue;
+
+ var typeFqn = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var converterFqn = converterMembers[0]
+ .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var comparerFqn = comparerMembers[0]
+ .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+
+ results.Add(new VogenValueObjectRecord(typeFqn, converterFqn, comparerFqn));
+ }
+
+ foreach (var childNs in ns.GetNamespaceMembers())
+ {
+ FindVogenValueObjectsWithEfConverters(childNs, results);
+ }
+ }
+
+ internal static bool IsVogenValueObject(INamedTypeSymbol typeSymbol)
+ {
+ foreach (var attr in typeSymbol.GetAttributes())
+ {
+ var attrClass = attr.AttributeClass;
+ if (
+ attrClass is not null
+ && attrClass.IsGenericType
+ && attrClass.Name == "ValueObjectAttribute"
+ && attrClass.ContainingNamespace.ToDisplayString() == "Vogen"
+ )
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Scans every contracts assembly and every module's implementation assembly
+ /// for Vogen value objects with EF Core value converters. Dedups on
+ /// IAssemblySymbol so a shared assembly is only walked once.
+ ///
+ internal static void Discover(
+ List modules,
+ Dictionary moduleSymbols,
+ Dictionary contractsAssemblySymbols,
+ List vogenValueObjects
+ )
+ {
+ var voScannedAssemblies = new HashSet(SymbolEqualityComparer.Default);
+
+ foreach (var kvp in contractsAssemblySymbols)
+ {
+ if (voScannedAssemblies.Add(kvp.Value))
+ {
+ FindVogenValueObjectsWithEfConverters(kvp.Value.GlobalNamespace, vogenValueObjects);
+ }
+ }
+
+ SymbolHelpers.ScanModuleAssemblies(
+ modules,
+ moduleSymbols,
+ (assembly, _) =>
+ {
+ if (voScannedAssemblies.Add(assembly))
+ {
+ FindVogenValueObjectsWithEfConverters(
+ assembly.GlobalNamespace,
+ vogenValueObjects
+ );
+ }
+ }
+ );
+ }
+
+ ///
+ /// If the type is a Vogen value object, returns the FQN of its underlying primitive type.
+ /// Otherwise returns the type's own FQN.
+ ///
+ internal static string ResolveUnderlyingType(ITypeSymbol typeSymbol)
+ {
+ foreach (var attr in typeSymbol.GetAttributes())
+ {
+ var attrClass = attr.AttributeClass;
+ if (attrClass is null)
+ continue;
+
+ // Vogen uses generic attribute ValueObjectAttribute
+ if (
+ attrClass.IsGenericType
+ && attrClass.Name == "ValueObjectAttribute"
+ && attrClass.ContainingNamespace.ToDisplayString() == "Vogen"
+ && attrClass.TypeArguments.Length == 1
+ )
+ {
+ return attrClass
+ .TypeArguments[0]
+ .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ }
+ }
+
+ return typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ }
+}
diff --git a/framework/SimpleModule.Generator/Discovery/Records/DataRecords.cs b/framework/SimpleModule.Generator/Discovery/Records/DataRecords.cs
new file mode 100644
index 00000000..4abfb5bf
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/Records/DataRecords.cs
@@ -0,0 +1,238 @@
+using System.Collections.Immutable;
+using System.Linq;
+
+namespace SimpleModule.Generator;
+
+internal readonly record struct DtoTypeInfoRecord(
+ string FullyQualifiedName,
+ string SafeName,
+ string? BaseTypeFqn,
+ ImmutableArray Properties
+)
+{
+ public bool Equals(DtoTypeInfoRecord other)
+ {
+ return FullyQualifiedName == other.FullyQualifiedName
+ && SafeName == other.SafeName
+ && BaseTypeFqn == other.BaseTypeFqn
+ && Properties.SequenceEqual(other.Properties);
+ }
+
+ public override int GetHashCode()
+ {
+ var hash = 17;
+ hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode());
+ hash = HashHelper.Combine(hash, SafeName.GetHashCode());
+ hash = HashHelper.Combine(hash, BaseTypeFqn?.GetHashCode() ?? 0);
+ hash = HashHelper.HashArray(hash, Properties);
+ return hash;
+ }
+}
+
+internal readonly record struct DtoPropertyInfoRecord(
+ string Name,
+ string TypeFqn,
+ string? UnderlyingTypeFqn,
+ bool HasSetter
+);
+
+internal readonly record struct DbContextInfoRecord(
+ string FullyQualifiedName,
+ string ModuleName,
+ bool IsIdentityDbContext,
+ string IdentityUserTypeFqn,
+ string IdentityRoleTypeFqn,
+ string IdentityKeyTypeFqn,
+ ImmutableArray DbSets,
+ SourceLocationRecord? Location
+)
+{
+ public bool Equals(DbContextInfoRecord other)
+ {
+ return FullyQualifiedName == other.FullyQualifiedName
+ && ModuleName == other.ModuleName
+ && IsIdentityDbContext == other.IsIdentityDbContext
+ && IdentityUserTypeFqn == other.IdentityUserTypeFqn
+ && IdentityRoleTypeFqn == other.IdentityRoleTypeFqn
+ && IdentityKeyTypeFqn == other.IdentityKeyTypeFqn
+ && DbSets.SequenceEqual(other.DbSets)
+ && Location == other.Location;
+ }
+
+ public override int GetHashCode()
+ {
+ var hash = 17;
+ hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode());
+ hash = HashHelper.Combine(hash, (ModuleName ?? "").GetHashCode());
+ hash = HashHelper.Combine(hash, IsIdentityDbContext.GetHashCode());
+ hash = HashHelper.Combine(hash, (IdentityUserTypeFqn ?? "").GetHashCode());
+ hash = HashHelper.Combine(hash, (IdentityRoleTypeFqn ?? "").GetHashCode());
+ hash = HashHelper.Combine(hash, (IdentityKeyTypeFqn ?? "").GetHashCode());
+ hash = HashHelper.HashArray(hash, DbSets);
+ hash = HashHelper.Combine(hash, Location.GetHashCode());
+ return hash;
+ }
+}
+
+internal readonly record struct DbSetInfoRecord(
+ string PropertyName,
+ string EntityFqn,
+ string EntityAssemblyName,
+ SourceLocationRecord? EntityLocation
+);
+
+internal readonly record struct EntityConfigInfoRecord(
+ string ConfigFqn,
+ string EntityFqn,
+ string ModuleName,
+ SourceLocationRecord? Location
+);
+
+internal readonly record struct ContractInterfaceInfoRecord(
+ string ContractsAssemblyName,
+ string InterfaceName,
+ int MethodCount,
+ SourceLocationRecord? Location
+);
+
+internal readonly record struct ContractImplementationRecord(
+ string InterfaceFqn,
+ string ImplementationFqn,
+ string ModuleName,
+ bool IsPublic,
+ bool IsAbstract,
+ bool DependsOnDbContext,
+ SourceLocationRecord? Location,
+ int Lifetime
+);
+
+internal readonly record struct PermissionClassRecord(
+ string FullyQualifiedName,
+ string ModuleName,
+ bool IsSealed,
+ ImmutableArray Fields,
+ SourceLocationRecord? Location
+)
+{
+ public bool Equals(PermissionClassRecord other) =>
+ FullyQualifiedName == other.FullyQualifiedName
+ && ModuleName == other.ModuleName
+ && IsSealed == other.IsSealed
+ && Fields.SequenceEqual(other.Fields)
+ && Location == other.Location;
+
+ public override int GetHashCode()
+ {
+ var hash = 17;
+ hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode());
+ hash = HashHelper.Combine(hash, (ModuleName ?? "").GetHashCode());
+ hash = HashHelper.Combine(hash, IsSealed.GetHashCode());
+ hash = HashHelper.HashArray(hash, Fields);
+ hash = HashHelper.Combine(hash, Location.GetHashCode());
+ return hash;
+ }
+}
+
+internal readonly record struct PermissionFieldRecord(
+ string FieldName,
+ string Value,
+ bool IsConstString,
+ SourceLocationRecord? Location
+);
+
+internal readonly record struct FeatureClassRecord(
+ string FullyQualifiedName,
+ string ModuleName,
+ bool IsSealed,
+ ImmutableArray Fields,
+ SourceLocationRecord? Location
+)
+{
+ public bool Equals(FeatureClassRecord other) =>
+ FullyQualifiedName == other.FullyQualifiedName
+ && ModuleName == other.ModuleName
+ && IsSealed == other.IsSealed
+ && Fields.SequenceEqual(other.Fields)
+ && Location == other.Location;
+
+ public override int GetHashCode()
+ {
+ var hash = 17;
+ hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode());
+ hash = HashHelper.Combine(hash, (ModuleName ?? "").GetHashCode());
+ hash = HashHelper.Combine(hash, IsSealed.GetHashCode());
+ hash = HashHelper.HashArray(hash, Fields);
+ hash = HashHelper.Combine(hash, Location.GetHashCode());
+ return hash;
+ }
+}
+
+internal readonly record struct FeatureFieldRecord(
+ string FieldName,
+ string Value,
+ bool IsConstString,
+ SourceLocationRecord? Location
+);
+
+internal readonly record struct InterceptorInfoRecord(
+ string FullyQualifiedName,
+ string ModuleName,
+ ImmutableArray ConstructorParamTypeFqns,
+ SourceLocationRecord? Location
+)
+{
+ public bool Equals(InterceptorInfoRecord other) =>
+ FullyQualifiedName == other.FullyQualifiedName
+ && ModuleName == other.ModuleName
+ && ConstructorParamTypeFqns.SequenceEqual(other.ConstructorParamTypeFqns)
+ && Location == other.Location;
+
+ public override int GetHashCode()
+ {
+ var hash = 17;
+ hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode());
+ hash = HashHelper.Combine(hash, (ModuleName ?? "").GetHashCode());
+ hash = HashHelper.HashArray(hash, ConstructorParamTypeFqns);
+ hash = HashHelper.Combine(hash, Location.GetHashCode());
+ return hash;
+ }
+}
+
+internal readonly record struct ModuleOptionsRecord(
+ string FullyQualifiedName,
+ string ModuleName,
+ SourceLocationRecord? Location
+)
+{
+ 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,
+ string ConverterFqn,
+ string ComparerFqn
+);
+
+internal readonly record struct AgentDefinitionRecord(string FullyQualifiedName, string ModuleName);
+
+internal readonly record struct AgentToolProviderRecord(
+ string FullyQualifiedName,
+ string ModuleName
+);
+
+internal readonly record struct KnowledgeSourceRecord(string FullyQualifiedName, string ModuleName);
diff --git a/framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs b/framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs
new file mode 100644
index 00000000..c1888640
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs
@@ -0,0 +1,119 @@
+using System.Collections.Immutable;
+using System.Linq;
+
+namespace SimpleModule.Generator;
+
+internal readonly record struct ModuleInfoRecord(
+ string FullyQualifiedName,
+ string ModuleName,
+ string AssemblyName,
+ bool HasConfigureServices,
+ bool HasConfigureEndpoints,
+ bool HasConfigureMenu,
+ bool HasConfigurePermissions,
+ bool HasConfigureMiddleware,
+ bool HasConfigureSettings,
+ bool HasConfigureFeatureFlags,
+ bool HasConfigureAgents,
+ bool HasConfigureRateLimits,
+ string RoutePrefix,
+ string ViewPrefix,
+ ImmutableArray Endpoints,
+ ImmutableArray Views,
+ SourceLocationRecord? Location
+)
+{
+ public bool Equals(ModuleInfoRecord other)
+ {
+ return FullyQualifiedName == other.FullyQualifiedName
+ && ModuleName == other.ModuleName
+ && AssemblyName == other.AssemblyName
+ && HasConfigureServices == other.HasConfigureServices
+ && HasConfigureEndpoints == other.HasConfigureEndpoints
+ && HasConfigureMenu == other.HasConfigureMenu
+ && HasConfigureMiddleware == other.HasConfigureMiddleware
+ && HasConfigurePermissions == other.HasConfigurePermissions
+ && HasConfigureSettings == other.HasConfigureSettings
+ && HasConfigureFeatureFlags == other.HasConfigureFeatureFlags
+ && HasConfigureAgents == other.HasConfigureAgents
+ && HasConfigureRateLimits == other.HasConfigureRateLimits
+ && RoutePrefix == other.RoutePrefix
+ && ViewPrefix == other.ViewPrefix
+ && Endpoints.SequenceEqual(other.Endpoints)
+ && Views.SequenceEqual(other.Views)
+ && Location == other.Location;
+ }
+
+ public override int GetHashCode()
+ {
+ var hash = 17;
+ hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode());
+ hash = HashHelper.Combine(hash, (ModuleName ?? "").GetHashCode());
+ hash = HashHelper.Combine(hash, (AssemblyName ?? "").GetHashCode());
+ hash = HashHelper.Combine(hash, HasConfigureServices.GetHashCode());
+ hash = HashHelper.Combine(hash, HasConfigureEndpoints.GetHashCode());
+ hash = HashHelper.Combine(hash, HasConfigureMenu.GetHashCode());
+ hash = HashHelper.Combine(hash, HasConfigureMiddleware.GetHashCode());
+ hash = HashHelper.Combine(hash, HasConfigurePermissions.GetHashCode());
+ hash = HashHelper.Combine(hash, HasConfigureSettings.GetHashCode());
+ hash = HashHelper.Combine(hash, HasConfigureFeatureFlags.GetHashCode());
+ hash = HashHelper.Combine(hash, HasConfigureAgents.GetHashCode());
+ hash = HashHelper.Combine(hash, HasConfigureRateLimits.GetHashCode());
+ hash = HashHelper.Combine(hash, (RoutePrefix ?? "").GetHashCode());
+ hash = HashHelper.Combine(hash, (ViewPrefix ?? "").GetHashCode());
+ hash = HashHelper.HashArray(hash, Endpoints);
+ hash = HashHelper.HashArray(hash, Views);
+ hash = HashHelper.Combine(hash, Location.GetHashCode());
+ return hash;
+ }
+}
+
+internal readonly record struct EndpointInfoRecord(
+ string FullyQualifiedName,
+ ImmutableArray RequiredPermissions,
+ bool AllowAnonymous,
+ string RouteTemplate,
+ string HttpMethod
+)
+{
+ public bool Equals(EndpointInfoRecord other)
+ {
+ return FullyQualifiedName == other.FullyQualifiedName
+ && AllowAnonymous == other.AllowAnonymous
+ && RouteTemplate == other.RouteTemplate
+ && HttpMethod == other.HttpMethod
+ && RequiredPermissions.SequenceEqual(other.RequiredPermissions);
+ }
+
+ public override int GetHashCode()
+ {
+ var hash = 17;
+ hash = HashHelper.Combine(hash, FullyQualifiedName.GetHashCode());
+ hash = HashHelper.Combine(hash, AllowAnonymous.GetHashCode());
+ hash = HashHelper.Combine(hash, (RouteTemplate ?? "").GetHashCode());
+ hash = HashHelper.Combine(hash, (HttpMethod ?? "").GetHashCode());
+ hash = HashHelper.HashArray(hash, RequiredPermissions);
+ return hash;
+ }
+}
+
+internal readonly record struct ViewInfoRecord(
+ string FullyQualifiedName,
+ string Page,
+ string RouteTemplate,
+ SourceLocationRecord? Location
+);
+
+internal readonly record struct ModuleDependencyRecord(
+ string ModuleName,
+ string DependsOnModuleName,
+ string ContractsAssemblyName
+);
+
+internal readonly record struct IllegalModuleReferenceRecord(
+ string ReferencingModuleName,
+ string ReferencingAssemblyName,
+ string ReferencedModuleName,
+ string ReferencedAssemblyName,
+ SourceLocationRecord? Location
+);
diff --git a/framework/SimpleModule.Generator/Discovery/Records/WorkingTypes.cs b/framework/SimpleModule.Generator/Discovery/Records/WorkingTypes.cs
new file mode 100644
index 00000000..6a584e91
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/Records/WorkingTypes.cs
@@ -0,0 +1,158 @@
+using System.Collections.Generic;
+
+namespace SimpleModule.Generator;
+
+// Mutable working types used only during symbol traversal.
+// After discovery completes, these are projected into equatable XxxRecord types.
+
+internal sealed class ModuleInfo
+{
+ public string FullyQualifiedName { get; set; } = "";
+ public string ModuleName { get; set; } = "";
+ public string AssemblyName { get; set; } = "";
+ public bool HasConfigureServices { get; set; }
+ public bool HasConfigureEndpoints { get; set; }
+ public bool HasConfigureMenu { get; set; }
+ public bool HasConfigurePermissions { get; set; }
+ public bool HasConfigureMiddleware { get; set; }
+ public bool HasConfigureSettings { get; set; }
+ public bool HasConfigureFeatureFlags { get; set; }
+ public bool HasConfigureAgents { get; set; }
+ public bool HasConfigureRateLimits { get; set; }
+ public string RoutePrefix { get; set; } = "";
+ public string ViewPrefix { get; set; } = "";
+ public List Endpoints { get; set; } = new();
+ public List Views { get; set; } = new();
+ public SourceLocationRecord? Location { get; set; }
+}
+
+internal sealed class EndpointInfo
+{
+ public string FullyQualifiedName { get; set; } = "";
+ public List RequiredPermissions { get; set; } = new();
+ public bool AllowAnonymous { get; set; }
+ public string RouteTemplate { get; set; } = "";
+ public string HttpMethod { get; set; } = "";
+}
+
+internal sealed class ViewInfo
+{
+ public string FullyQualifiedName { get; set; } = "";
+ public string? Page { get; set; }
+ public string InferredClassName { get; set; } = "";
+ public string RouteTemplate { get; set; } = "";
+ public SourceLocationRecord? Location { get; set; }
+}
+
+internal sealed class DtoTypeInfo
+{
+ public string FullyQualifiedName { get; set; } = "";
+ public string SafeName { get; set; } = "";
+ public string? BaseTypeFqn { get; set; }
+ public List Properties { get; set; } = new();
+}
+
+internal sealed class DtoPropertyInfo
+{
+ public string Name { get; set; } = "";
+ public string TypeFqn { get; set; } = "";
+
+ ///
+ /// For value objects (e.g. Vogen), the underlying primitive type FQN.
+ /// Null if the type is not a value object wrapper.
+ ///
+ public string? UnderlyingTypeFqn { get; set; }
+
+ public bool HasSetter { get; set; }
+}
+
+internal sealed class DbContextInfo
+{
+ public string FullyQualifiedName { get; set; } = "";
+ public string ModuleName { get; set; } = "";
+ public bool IsIdentityDbContext { get; set; }
+ public string IdentityUserTypeFqn { get; set; } = "";
+ public string IdentityRoleTypeFqn { get; set; } = "";
+ public string IdentityKeyTypeFqn { get; set; } = "";
+ public List DbSets { get; set; } = new();
+ public SourceLocationRecord? Location { get; set; }
+}
+
+internal sealed class DbSetInfo
+{
+ public string PropertyName { get; set; } = "";
+ public string EntityFqn { get; set; } = "";
+ public string EntityAssemblyName { get; set; } = "";
+ public SourceLocationRecord? EntityLocation { get; set; }
+}
+
+internal sealed class EntityConfigInfo
+{
+ public string ConfigFqn { get; set; } = "";
+ public string EntityFqn { get; set; } = "";
+ public string ModuleName { get; set; } = "";
+ public SourceLocationRecord? Location { get; set; }
+}
+
+internal sealed class ContractImplementationInfo
+{
+ public string InterfaceFqn { get; set; } = "";
+ public string ImplementationFqn { get; set; } = "";
+ public string ModuleName { get; set; } = "";
+ public bool IsPublic { get; set; }
+ public bool IsAbstract { get; set; }
+ public bool DependsOnDbContext { get; set; }
+ public SourceLocationRecord? Location { get; set; }
+ public int Lifetime { get; set; } = 1; // Default: Scoped (ServiceLifetime.Scoped = 1)
+}
+
+internal sealed class PermissionClassInfo
+{
+ public string FullyQualifiedName { get; set; } = "";
+ public string ModuleName { get; set; } = "";
+ public bool IsSealed { get; set; }
+ public List Fields { get; set; } = new();
+ public SourceLocationRecord? Location { get; set; }
+}
+
+internal sealed class PermissionFieldInfo
+{
+ public string FieldName { get; set; } = "";
+ public string Value { get; set; } = "";
+ public bool IsConstString { get; set; }
+ public SourceLocationRecord? Location { get; set; }
+}
+
+internal sealed class FeatureClassInfo
+{
+ public string FullyQualifiedName { get; set; } = "";
+ public string ModuleName { get; set; } = "";
+ public bool IsSealed { get; set; }
+ public List Fields { get; set; } = new();
+ public SourceLocationRecord? Location { get; set; }
+}
+
+internal sealed class FeatureFieldInfo
+{
+ public string FieldName { get; set; } = "";
+ public string Value { get; set; } = "";
+ public bool IsConstString { get; set; }
+ public SourceLocationRecord? Location { get; set; }
+}
+
+internal sealed class InterceptorInfo
+{
+ public string FullyQualifiedName { get; set; } = "";
+ public string ModuleName { get; set; } = "";
+ public List ConstructorParamTypeFqns { get; set; } = new();
+ public SourceLocationRecord? Location { get; set; }
+}
+
+///
+/// Shared mutable working type for discovered interface implementors (agents, tool providers, knowledge sources).
+///
+internal sealed class DiscoveredTypeInfo
+{
+ public string FullyQualifiedName { get; set; } = "";
+ public string ModuleName { get; set; } = "";
+}
diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
index 14abe3db..337b79af 100644
--- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
+++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs
@@ -1,7 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Collections.Immutable;
-using System.Linq;
using System.Threading;
using Microsoft.CodeAnalysis;
@@ -9,29 +7,6 @@ namespace SimpleModule.Generator;
internal static class SymbolDiscovery
{
- ///
- /// Extracts a serializable source location from a symbol, if available.
- /// Returns null for symbols only available in metadata (compiled DLLs).
- ///
- private static SourceLocationRecord? GetSourceLocation(ISymbol symbol)
- {
- foreach (var loc in symbol.Locations)
- {
- if (loc.IsInSource)
- {
- var span = loc.GetLineSpan();
- return new SourceLocationRecord(
- span.Path,
- span.StartLinePosition.Line,
- span.StartLinePosition.Character,
- span.EndLinePosition.Line,
- span.EndLinePosition.Character
- );
- }
- }
- return null;
- }
-
internal static DiscoveryData Extract(
Compilation compilation,
CancellationToken cancellationToken
@@ -39,77 +14,45 @@ CancellationToken cancellationToken
{
var hostAssemblyName = compilation.Assembly.Name;
- var moduleAttributeSymbol = compilation.GetTypeByMetadataName(
- "SimpleModule.Core.ModuleAttribute"
- );
- if (moduleAttributeSymbol is null)
+ var symbols = CoreSymbols.TryResolve(compilation);
+ if (symbols is null)
return DiscoveryData.Empty;
+ var s = symbols.Value;
- var dtoAttributeSymbol = compilation.GetTypeByMetadataName(
- "SimpleModule.Core.DtoAttribute"
- );
-
- var endpointInterfaceSymbol = compilation.GetTypeByMetadataName(
- "SimpleModule.Core.IEndpoint"
- );
-
- var viewEndpointInterfaceSymbol = compilation.GetTypeByMetadataName(
- "SimpleModule.Core.IViewEndpoint"
- );
+ // Single-pass reference classification. Every discovery phase that scans
+ // referenced assemblies gets to reuse these pre-classified lists instead
+ // of re-iterating compilation.References + re-calling GetAssemblyOrModuleSymbol.
+ var refAssemblies = new List();
+ var contractsAssemblies = new List();
+ foreach (var reference in compilation.References)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
- var agentDefinitionSymbol = compilation.GetTypeByMetadataName(
- "SimpleModule.Core.Agents.IAgentDefinition"
- );
- var agentToolProviderSymbol = compilation.GetTypeByMetadataName(
- "SimpleModule.Core.Agents.IAgentToolProvider"
- );
- var knowledgeSourceSymbol = compilation.GetTypeByMetadataName(
- "SimpleModule.Core.Rag.IKnowledgeSource"
- );
+ if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol asm)
+ continue;
- // Resolve focused sub-interface symbols for module capability detection
- var moduleServicesSymbol = compilation.GetTypeByMetadataName(
- "SimpleModule.Core.IModuleServices"
- );
- var moduleMenuSymbol = compilation.GetTypeByMetadataName("SimpleModule.Core.IModuleMenu");
- var moduleMiddlewareSymbol = compilation.GetTypeByMetadataName(
- "SimpleModule.Core.IModuleMiddleware"
- );
- var moduleSettingsSymbol = compilation.GetTypeByMetadataName(
- "SimpleModule.Core.IModuleSettings"
- );
+ refAssemblies.Add(asm);
+ if (asm.Name.EndsWith(".Contracts", StringComparison.OrdinalIgnoreCase))
+ contractsAssemblies.Add(asm);
+ }
var modules = new List();
- foreach (var reference in compilation.References)
+ foreach (var assemblySymbol in refAssemblies)
{
cancellationToken.ThrowIfCancellationRequested();
- if (
- compilation.GetAssemblyOrModuleSymbol(reference)
- is not IAssemblySymbol assemblySymbol
- )
- continue;
-
- FindModuleTypes(
+ ModuleFinder.FindModuleTypes(
assemblySymbol.GlobalNamespace,
- moduleAttributeSymbol,
- moduleServicesSymbol,
- moduleMenuSymbol,
- moduleMiddlewareSymbol,
- moduleSettingsSymbol,
+ s,
modules,
cancellationToken
);
}
- FindModuleTypes(
+ ModuleFinder.FindModuleTypes(
compilation.Assembly.GlobalNamespace,
- moduleAttributeSymbol,
- moduleServicesSymbol,
- moduleMenuSymbol,
- moduleMiddlewareSymbol,
- moduleSettingsSymbol,
+ s,
modules,
cancellationToken
);
@@ -127,173 +70,48 @@ is not IAssemblySymbol assemblySymbol
moduleSymbols[module.FullyQualifiedName] = typeSymbol;
}
- // Discover IEndpoint implementors per module assembly.
- // Classification is by interface type: IViewEndpoint -> view, IEndpoint -> API.
- // Scan each assembly once, then match endpoints to the closest module by namespace.
- if (endpointInterfaceSymbol is not null)
+ // Dictionary by module NAME for O(1) endpoint/view attribution inside EndpointFinder.
+ // Duplicate names are already caught by SM0040 — we just take the first entry.
+ var modulesByName = new Dictionary(StringComparer.Ordinal);
+ foreach (var module in modules)
{
- var endpointScannedAssemblies = new HashSet(
- SymbolEqualityComparer.Default
- );
- foreach (var module in modules)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol))
- continue;
-
- var assembly = typeSymbol.ContainingAssembly;
- if (!endpointScannedAssemblies.Add(assembly))
- continue;
-
- var rawEndpoints = new List();
- var rawViews = new List();
- FindEndpointTypes(
- assembly.GlobalNamespace,
- endpointInterfaceSymbol,
- viewEndpointInterfaceSymbol,
- rawEndpoints,
- rawViews,
- cancellationToken
- );
-
- // Match each endpoint/view to the module whose namespace is closest
- foreach (var ep in rawEndpoints)
- {
- var epFqn = TypeMappingHelpers.StripGlobalPrefix(ep.FullyQualifiedName);
- var ownerName = FindClosestModuleName(epFqn, modules);
- var owner = modules.Find(m => m.ModuleName == ownerName);
- if (owner is not null)
- owner.Endpoints.Add(ep);
- }
-
- // Pre-compute module namespace per module name for page inference
- var moduleNsByName = new Dictionary();
- foreach (var m in modules)
- {
- if (!moduleNsByName.ContainsKey(m.ModuleName))
- {
- var mFqn = TypeMappingHelpers.StripGlobalPrefix(m.FullyQualifiedName);
- moduleNsByName[m.ModuleName] = TypeMappingHelpers.ExtractNamespace(mFqn);
- }
- }
-
- foreach (var v in rawViews)
- {
- var vFqn = TypeMappingHelpers.StripGlobalPrefix(v.FullyQualifiedName);
- var ownerName = FindClosestModuleName(vFqn, modules);
- var owner = modules.Find(m => m.ModuleName == ownerName);
- if (owner is not null)
- {
- // Derive page name from namespace segments between module NS and class name.
- // e.g. SimpleModule.Users.Pages.Account.LoginEndpoint → Users/Account/Login
- if (v.Page is null)
- {
- var moduleNs = moduleNsByName[ownerName];
- var typeNs = TypeMappingHelpers.ExtractNamespace(vFqn);
-
- // Extract segments after the module namespace, stripping Views/Pages
- var remaining =
- typeNs.Length > moduleNs.Length
- ? typeNs.Substring(moduleNs.Length).TrimStart('.')
- : "";
-
- var segments = remaining.Split('.');
- var pathParts = new List();
- foreach (var seg in segments)
- {
- if (
- seg.Length > 0
- && !seg.Equals("Views", StringComparison.Ordinal)
- && !seg.Equals("Pages", StringComparison.Ordinal)
- )
- {
- pathParts.Add(seg);
- }
- }
+ if (!modulesByName.ContainsKey(module.ModuleName))
+ modulesByName[module.ModuleName] = module;
+ }
- var subPath =
- pathParts.Count > 0 ? string.Join("/", pathParts) + "/" : "";
- v.Page = ownerName + "/" + subPath + v.InferredClassName;
- }
+ // Pre-built namespace index for O(1)-amortised module attribution across all finders.
+ var moduleNsIndex = SymbolHelpers.BuildModuleNamespaceIndex(modules);
- owner.Views.Add(v);
- }
- }
- }
- }
+ // Discover IEndpoint and IViewEndpoint implementors per module assembly
+ EndpointFinder.Discover(
+ modules,
+ moduleSymbols,
+ modulesByName,
+ moduleNsIndex,
+ s,
+ cancellationToken
+ );
- // Discover DbContext subclasses and IEntityTypeConfiguration per module assembly.
- // Scan each assembly once, then match DbContexts/configs to the nearest module by namespace.
+ // Discover DbContext subclasses and IEntityTypeConfiguration per module assembly
var dbContexts = new List();
var entityConfigs = new List();
- var scannedAssemblies = new HashSet(SymbolEqualityComparer.Default);
- foreach (var module in modules)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol))
- continue;
-
- var assembly = typeSymbol.ContainingAssembly;
- if (!scannedAssemblies.Add(assembly))
- continue;
-
- // Collect unmatched items from this assembly
- var rawDbContexts = new List();
- var rawEntityConfigs = new List();
- FindDbContextTypes(assembly.GlobalNamespace, "", rawDbContexts, cancellationToken);
- FindEntityConfigTypes(
- assembly.GlobalNamespace,
- "",
- rawEntityConfigs,
- cancellationToken
- );
-
- // Match each DbContext to the module whose namespace is closest
- foreach (var ctx in rawDbContexts)
- {
- var ctxNs = TypeMappingHelpers.StripGlobalPrefix(ctx.FullyQualifiedName);
- ctx.ModuleName = FindClosestModuleName(ctxNs, modules);
- dbContexts.Add(ctx);
- }
-
- foreach (var cfg in rawEntityConfigs)
- {
- var cfgNs = TypeMappingHelpers.StripGlobalPrefix(cfg.ConfigFqn);
- cfg.ModuleName = FindClosestModuleName(cfgNs, modules);
- entityConfigs.Add(cfg);
- }
- }
+ DbContextFinder.Discover(
+ modules,
+ moduleSymbols,
+ moduleNsIndex,
+ dbContexts,
+ entityConfigs,
+ cancellationToken
+ );
var dtoTypes = new List();
- if (dtoAttributeSymbol is not null)
- {
- foreach (var reference in compilation.References)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- if (
- compilation.GetAssemblyOrModuleSymbol(reference)
- is not IAssemblySymbol assemblySymbol
- )
- continue;
-
- FindDtoTypes(
- assemblySymbol.GlobalNamespace,
- dtoAttributeSymbol,
- dtoTypes,
- cancellationToken
- );
- }
-
- FindDtoTypes(
- compilation.Assembly.GlobalNamespace,
- dtoAttributeSymbol,
- dtoTypes,
- cancellationToken
- );
- }
+ DtoFinder.DiscoverAttributedDtos(
+ refAssemblies,
+ compilation.Assembly.GlobalNamespace,
+ s,
+ dtoTypes,
+ cancellationToken
+ );
// --- Dependency inference ---
cancellationToken.ThrowIfCancellationRequested();
@@ -315,48 +133,14 @@ is not IAssemblySymbol assemblySymbol
var contractsAssemblySymbols = new Dictionary(
StringComparer.OrdinalIgnoreCase
);
-
- foreach (var reference in compilation.References)
- {
- if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol asm)
- continue;
-
- var asmName = asm.Name;
- if (!asmName.EndsWith(".Contracts", StringComparison.OrdinalIgnoreCase))
- continue;
-
- var baseName = asmName.Substring(0, asmName.Length - ".Contracts".Length);
-
- // Try exact match on assembly name
- if (moduleAssemblyMap.TryGetValue(baseName, out var moduleName))
- {
- contractsAssemblyMap[asmName] = moduleName;
- contractsAssemblySymbols[asmName] = asm;
- continue;
- }
-
- // Try matching last segment of baseName to module names (case-insensitive)
- var lastDot = baseName.LastIndexOf('.');
- var lastSegment = lastDot >= 0 ? baseName.Substring(lastDot + 1) : baseName;
-
- foreach (var kvp in moduleAssemblyMap)
- {
- if (string.Equals(lastSegment, kvp.Value, StringComparison.OrdinalIgnoreCase))
- {
- contractsAssemblyMap[asmName] = kvp.Value;
- contractsAssemblySymbols[asmName] = asm;
- break;
- }
- }
- }
+ ContractFinder.BuildContractsAssemblyMap(
+ contractsAssemblies,
+ moduleAssemblyMap,
+ contractsAssemblyMap,
+ contractsAssemblySymbols
+ );
// Convention-based DTO discovery: all public types in *.Contracts assemblies
- var noDtoAttrSymbol = compilation.GetTypeByMetadataName(
- "SimpleModule.Core.NoDtoGenerationAttribute"
- );
- var eventInterfaceSymbol = compilation.GetTypeByMetadataName(
- "SimpleModule.Core.Events.IEvent"
- );
var existingDtoFqns = new HashSet();
foreach (var d in dtoTypes)
existingDtoFqns.Add(d.FullyQualifiedName);
@@ -364,1705 +148,114 @@ is not IAssemblySymbol assemblySymbol
foreach (var kvp in contractsAssemblySymbols)
{
cancellationToken.ThrowIfCancellationRequested();
- FindConventionDtoTypes(
+ DtoFinder.FindConventionDtoTypes(
kvp.Value.GlobalNamespace,
- noDtoAttrSymbol,
- eventInterfaceSymbol,
+ s.NoDtoAttribute,
+ s.EventInterface,
existingDtoFqns,
dtoTypes,
cancellationToken
);
}
- // Step 3: Scan contract interfaces
+ // Step 3/3b: Contract interfaces and implementations
var contractInterfaces = new List();
- foreach (var kvp in contractsAssemblySymbols)
- {
- ScanContractInterfaces(kvp.Value.GlobalNamespace, kvp.Key, contractInterfaces);
- }
-
- // Step 3b: Find implementations of contract interfaces in module assemblies
var contractImplementations = new List();
- foreach (var module in modules)
- {
- if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol))
- continue;
-
- var moduleAssembly = typeSymbol.ContainingAssembly;
-
- // Find which contracts assembly this module owns
- var expectedContractsAsm = moduleAssembly.Name + ".Contracts";
- if (!contractsAssemblySymbols.ContainsKey(expectedContractsAsm))
- continue;
-
- // Get the interface FQNs from this module's contracts
- var moduleContractInterfaceFqns = new HashSet();
- foreach (var ci in contractInterfaces)
- {
- if (ci.ContractsAssemblyName == expectedContractsAsm)
- moduleContractInterfaceFqns.Add(ci.InterfaceName);
- }
- if (moduleContractInterfaceFqns.Count == 0)
- continue;
-
- // Scan module assembly for implementations
- FindContractImplementations(
- moduleAssembly.GlobalNamespace,
- moduleContractInterfaceFqns,
- module.ModuleName,
- compilation,
- contractImplementations
- );
- }
+ ContractFinder.DiscoverInterfacesAndImplementations(
+ modules,
+ moduleSymbols,
+ contractsAssemblySymbols,
+ compilation,
+ contractInterfaces,
+ contractImplementations
+ );
// Step 3c: Find IModulePermissions implementors in module and contracts assemblies
var permissionClasses = new List();
- var modulePermissionsSymbol = compilation.GetTypeByMetadataName(
- "SimpleModule.Core.Authorization.IModulePermissions"
+ PermissionFeatureFinder.DiscoverPermissions(
+ modules,
+ moduleSymbols,
+ contractsAssemblySymbols,
+ contractsAssemblyMap,
+ s,
+ permissionClasses
);
- if (modulePermissionsSymbol is not null)
- {
- foreach (var module in modules)
- {
- if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol))
- continue;
-
- var moduleAssembly = typeSymbol.ContainingAssembly;
- FindPermissionClasses(
- moduleAssembly.GlobalNamespace,
- modulePermissionsSymbol,
- module.ModuleName,
- permissionClasses
- );
- }
-
- // Also scan contracts assemblies for permission classes
- foreach (var kvp in contractsAssemblySymbols)
- {
- if (contractsAssemblyMap.TryGetValue(kvp.Key, out var moduleName))
- {
- FindPermissionClasses(
- kvp.Value.GlobalNamespace,
- modulePermissionsSymbol,
- moduleName,
- permissionClasses
- );
- }
- }
- }
// Step 3d: Find IModuleFeatures implementors in module and contracts assemblies
var featureClasses = new List();
- var moduleFeaturesSymbol = compilation.GetTypeByMetadataName(
- "SimpleModule.Core.FeatureFlags.IModuleFeatures"
+ PermissionFeatureFinder.DiscoverFeatures(
+ modules,
+ moduleSymbols,
+ contractsAssemblySymbols,
+ contractsAssemblyMap,
+ s,
+ featureClasses
);
- if (moduleFeaturesSymbol is not null)
- {
- foreach (var module in modules)
- {
- if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol))
- continue;
- var moduleAssembly = typeSymbol.ContainingAssembly;
- FindFeatureClasses(
- moduleAssembly.GlobalNamespace,
- moduleFeaturesSymbol,
- module.ModuleName,
- featureClasses
- );
- }
-
- // Also scan contracts assemblies for feature classes
- foreach (var kvp in contractsAssemblySymbols)
- {
- if (contractsAssemblyMap.TryGetValue(kvp.Key, out var moduleName))
- {
- FindFeatureClasses(
- kvp.Value.GlobalNamespace,
- moduleFeaturesSymbol,
- moduleName,
- featureClasses
- );
- }
- }
- }
-
- // Step 3e: Find ISaveChangesInterceptor implementors in module assemblies
+ // Step 3e: ISaveChangesInterceptor implementors
var interceptors = new List();
- var saveChangesInterceptorSymbol = compilation.GetTypeByMetadataName(
- "Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor"
- );
- if (saveChangesInterceptorSymbol is not null)
- {
- ScanModuleAssemblies(
- modules,
- moduleSymbols,
- (assembly, module) =>
- {
- FindInterceptorTypes(
- assembly.GlobalNamespace,
- saveChangesInterceptorSymbol,
- module.ModuleName,
- interceptors
- );
- }
- );
- }
+ InterceptorFinder.Discover(modules, moduleSymbols, s, interceptors);
- // Step 3e: Discover Vogen value objects with EF Core value converters.
- // Scan Contracts assemblies and module assemblies only.
+ // Step 3e': Vogen value objects with EF Core value converters
var vogenValueObjects = new List();
- var voScannedAssemblies = new HashSet(SymbolEqualityComparer.Default);
+ VogenFinder.Discover(modules, moduleSymbols, contractsAssemblySymbols, vogenValueObjects);
- foreach (var kvp in contractsAssemblySymbols)
- {
- if (voScannedAssemblies.Add(kvp.Value))
- {
- FindVogenValueObjectsWithEfConverters(kvp.Value.GlobalNamespace, vogenValueObjects);
- }
- }
-
- ScanModuleAssemblies(
+ // Step 3f: Find IModuleOptions implementors in module and contracts assemblies
+ var moduleOptionsList = new List();
+ PermissionFeatureFinder.DiscoverModuleOptions(
modules,
moduleSymbols,
- (assembly, _) =>
- {
- if (voScannedAssemblies.Add(assembly))
- {
- FindVogenValueObjectsWithEfConverters(
- assembly.GlobalNamespace,
- vogenValueObjects
- );
- }
- }
+ contractsAssemblySymbols,
+ contractsAssemblyMap,
+ s,
+ moduleOptionsList
);
- // 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)
- {
- 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 3g: Find IAgentDefinition, IAgentToolProvider, and IKnowledgeSource implementors
+ // Step 3g: Agent definitions, tool providers, knowledge sources
var agentDefinitions = new List();
var agentToolProviders = new List();
var knowledgeSources = new List();
-
- if (agentDefinitionSymbol is not null)
- {
- ScanModuleAssemblies(
- modules,
- moduleSymbols,
- (assembly, module) =>
- FindImplementors(
- assembly.GlobalNamespace,
- agentDefinitionSymbol,
- module.ModuleName,
- agentDefinitions
- )
- );
- }
-
- if (agentToolProviderSymbol is not null)
- {
- ScanModuleAssemblies(
- modules,
- moduleSymbols,
- (assembly, module) =>
- FindImplementors(
- assembly.GlobalNamespace,
- agentToolProviderSymbol,
- module.ModuleName,
- agentToolProviders
- )
- );
- }
-
- if (knowledgeSourceSymbol is not null)
- {
- ScanModuleAssemblies(
- modules,
- moduleSymbols,
- (assembly, module) =>
- FindImplementors(
- assembly.GlobalNamespace,
- knowledgeSourceSymbol,
- module.ModuleName,
- knowledgeSources
- )
- );
- }
+ AgentFinder.DiscoverAll(
+ modules,
+ moduleSymbols,
+ s,
+ agentDefinitions,
+ agentToolProviders,
+ knowledgeSources
+ );
// Step 4: Detect dependencies and illegal references
var dependencies = new List();
var illegalReferences = new List();
-
- foreach (var module in modules)
- {
- if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol))
- continue;
-
- var moduleAssembly = typeSymbol.ContainingAssembly;
- var thisModuleName = module.ModuleName;
-
- foreach (var asmModule in moduleAssembly.Modules)
- {
- foreach (var referencedAsm in asmModule.ReferencedAssemblySymbols)
- {
- var refName = referencedAsm.Name;
-
- // Check for illegal direct module-to-module reference
- if (
- moduleAssemblyMap.TryGetValue(refName, out var referencedModuleName)
- && !string.Equals(
- referencedModuleName,
- thisModuleName,
- StringComparison.OrdinalIgnoreCase
- )
- )
- {
- illegalReferences.Add(
- new IllegalModuleReferenceRecord(
- thisModuleName,
- moduleAssembly.Name,
- referencedModuleName,
- refName,
- module.Location
- )
- );
- }
-
- // Check for dependency via contracts
- if (
- contractsAssemblyMap.TryGetValue(refName, out var depModuleName)
- && !string.Equals(
- depModuleName,
- thisModuleName,
- StringComparison.OrdinalIgnoreCase
- )
- )
- {
- dependencies.Add(
- new ModuleDependencyRecord(thisModuleName, depModuleName, refName)
- );
- }
- }
- }
- }
-
- return new DiscoveryData(
- modules
- .Select(m => new ModuleInfoRecord(
- m.FullyQualifiedName,
- m.ModuleName,
- m.AssemblyName,
- m.HasConfigureServices,
- m.HasConfigureEndpoints,
- m.HasConfigureMenu,
- m.HasConfigurePermissions,
- m.HasConfigureMiddleware,
- m.HasConfigureSettings,
- m.HasConfigureFeatureFlags,
- m.HasConfigureAgents,
- m.HasConfigureRateLimits,
- m.RoutePrefix,
- m.ViewPrefix,
- m.Endpoints.Select(e => new EndpointInfoRecord(
- e.FullyQualifiedName,
- e.RequiredPermissions.ToImmutableArray(),
- e.AllowAnonymous,
- e.RouteTemplate,
- e.HttpMethod
- ))
- .ToImmutableArray(),
- m.Views.Select(v => new ViewInfoRecord(
- v.FullyQualifiedName,
- v.Page ?? "",
- v.RouteTemplate,
- v.Location
- ))
- .ToImmutableArray(),
- m.Location
- ))
- .ToImmutableArray(),
- dtoTypes
- .Select(d => new DtoTypeInfoRecord(
- d.FullyQualifiedName,
- d.SafeName,
- d.BaseTypeFqn,
- d.Properties.Select(p => new DtoPropertyInfoRecord(
- p.Name,
- p.TypeFqn,
- p.UnderlyingTypeFqn,
- p.HasSetter
- ))
- .ToImmutableArray()
- ))
- .ToImmutableArray(),
- dbContexts
- .Select(c => new DbContextInfoRecord(
- c.FullyQualifiedName,
- c.ModuleName,
- c.IsIdentityDbContext,
- c.IdentityUserTypeFqn,
- c.IdentityRoleTypeFqn,
- c.IdentityKeyTypeFqn,
- c.DbSets.Select(d => new DbSetInfoRecord(
- d.PropertyName,
- d.EntityFqn,
- d.EntityAssemblyName,
- d.EntityLocation
- ))
- .ToImmutableArray(),
- c.Location
- ))
- .ToImmutableArray(),
- entityConfigs
- .Select(e => new EntityConfigInfoRecord(
- e.ConfigFqn,
- e.EntityFqn,
- e.ModuleName,
- e.Location
- ))
- .ToImmutableArray(),
- dependencies.ToImmutableArray(),
- illegalReferences.ToImmutableArray(),
- contractInterfaces.ToImmutableArray(),
- contractImplementations
- .Select(c => new ContractImplementationRecord(
- c.InterfaceFqn,
- c.ImplementationFqn,
- c.ModuleName,
- c.IsPublic,
- c.IsAbstract,
- c.DependsOnDbContext,
- c.Location,
- c.Lifetime
- ))
- .ToImmutableArray(),
- permissionClasses
- .Select(p => new PermissionClassRecord(
- p.FullyQualifiedName,
- p.ModuleName,
- p.IsSealed,
- p.Fields.Select(f => new PermissionFieldRecord(
- f.FieldName,
- f.Value,
- f.IsConstString,
- f.Location
- ))
- .ToImmutableArray(),
- p.Location
- ))
- .ToImmutableArray(),
- featureClasses
- .Select(f => new FeatureClassRecord(
- f.FullyQualifiedName,
- f.ModuleName,
- f.IsSealed,
- f.Fields.Select(ff => new FeatureFieldRecord(
- ff.FieldName,
- ff.Value,
- ff.IsConstString,
- ff.Location
- ))
- .ToImmutableArray(),
- f.Location
- ))
- .ToImmutableArray(),
- interceptors
- .Select(i => new InterceptorInfoRecord(
- i.FullyQualifiedName,
- i.ModuleName,
- i.ConstructorParamTypeFqns.ToImmutableArray(),
- i.Location
- ))
- .ToImmutableArray(),
- vogenValueObjects.ToImmutableArray(),
- moduleOptionsList.ToImmutableArray(),
- agentDefinitions
- .Select(a => new AgentDefinitionRecord(a.FullyQualifiedName, a.ModuleName))
- .ToImmutableArray(),
- agentToolProviders
- .Select(a => new AgentToolProviderRecord(a.FullyQualifiedName, a.ModuleName))
- .ToImmutableArray(),
- knowledgeSources
- .Select(k => new KnowledgeSourceRecord(k.FullyQualifiedName, k.ModuleName))
- .ToImmutableArray(),
- contractsAssemblyMap.Keys.ToImmutableArray(),
- compilation.GetTypeByMetadataName("SimpleModule.Agents.SimpleModuleAgentExtensions")
- is not null,
- hostAssemblyName
+ DependencyAnalyzer.Analyze(
+ modules,
+ moduleSymbols,
+ moduleAssemblyMap,
+ contractsAssemblyMap,
+ dependencies,
+ illegalReferences
);
- }
- private static void FindModuleTypes(
- INamespaceSymbol namespaceSymbol,
- INamedTypeSymbol moduleAttributeSymbol,
- INamedTypeSymbol? moduleServicesSymbol,
- INamedTypeSymbol? moduleMenuSymbol,
- INamedTypeSymbol? moduleMiddlewareSymbol,
- INamedTypeSymbol? moduleSettingsSymbol,
- List modules,
- CancellationToken cancellationToken
- )
- {
- foreach (var member in namespaceSymbol.GetMembers())
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- if (member is INamespaceSymbol childNamespace)
- {
- FindModuleTypes(
- childNamespace,
- moduleAttributeSymbol,
- moduleServicesSymbol,
- moduleMenuSymbol,
- moduleMiddlewareSymbol,
- moduleSettingsSymbol,
- modules,
- cancellationToken
- );
- }
- else if (member is INamedTypeSymbol typeSymbol)
- {
- foreach (var attr in typeSymbol.GetAttributes())
- {
- if (
- SymbolEqualityComparer.Default.Equals(
- attr.AttributeClass,
- moduleAttributeSymbol
- )
- )
- {
- var moduleName =
- attr.ConstructorArguments.Length > 0
- ? attr.ConstructorArguments[0].Value as string ?? ""
- : "";
- var routePrefix = "";
- var viewPrefix = "";
- foreach (var namedArg in attr.NamedArguments)
- {
- if (
- namedArg.Key == "RoutePrefix"
- && namedArg.Value.Value is string prefix
- )
- {
- routePrefix = prefix;
- }
- else if (
- namedArg.Key == "ViewPrefix"
- && namedArg.Value.Value is string vPrefix
- )
- {
- viewPrefix = vPrefix;
- }
- }
-
- modules.Add(
- new ModuleInfo
- {
- FullyQualifiedName = typeSymbol.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- ),
- ModuleName = moduleName,
- HasConfigureServices =
- DeclaresMethod(typeSymbol, "ConfigureServices")
- || (
- moduleServicesSymbol is not null
- && ImplementsInterface(typeSymbol, moduleServicesSymbol)
- ),
- HasConfigureEndpoints = DeclaresMethod(
- typeSymbol,
- "ConfigureEndpoints"
- ),
- HasConfigureMenu =
- DeclaresMethod(typeSymbol, "ConfigureMenu")
- || (
- moduleMenuSymbol is not null
- && ImplementsInterface(typeSymbol, moduleMenuSymbol)
- ),
- HasConfigureMiddleware =
- DeclaresMethod(typeSymbol, "ConfigureMiddleware")
- || (
- moduleMiddlewareSymbol is not null
- && ImplementsInterface(typeSymbol, moduleMiddlewareSymbol)
- ),
- HasConfigurePermissions = DeclaresMethod(
- typeSymbol,
- "ConfigurePermissions"
- ),
- HasConfigureSettings =
- DeclaresMethod(typeSymbol, "ConfigureSettings")
- || (
- moduleSettingsSymbol is not null
- && ImplementsInterface(typeSymbol, moduleSettingsSymbol)
- ),
- HasConfigureFeatureFlags = DeclaresMethod(
- typeSymbol,
- "ConfigureFeatureFlags"
- ),
- HasConfigureAgents = DeclaresMethod(typeSymbol, "ConfigureAgents"),
- HasConfigureRateLimits = DeclaresMethod(
- typeSymbol,
- "ConfigureRateLimits"
- ),
- RoutePrefix = routePrefix,
- ViewPrefix = viewPrefix,
- AssemblyName = typeSymbol.ContainingAssembly.Name,
- Location = GetSourceLocation(typeSymbol),
- }
- );
- break;
- }
- }
- }
- }
- }
-
- private static void FindEndpointTypes(
- INamespaceSymbol namespaceSymbol,
- INamedTypeSymbol endpointInterfaceSymbol,
- INamedTypeSymbol? viewEndpointInterfaceSymbol,
- List endpoints,
- List views,
- CancellationToken cancellationToken
- )
- {
- foreach (var member in namespaceSymbol.GetMembers())
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- if (member is INamespaceSymbol childNamespace)
- {
- FindEndpointTypes(
- childNamespace,
- endpointInterfaceSymbol,
- viewEndpointInterfaceSymbol,
- endpoints,
- views,
- cancellationToken
- );
- }
- else if (member is INamedTypeSymbol typeSymbol)
- {
- if (!typeSymbol.IsAbstract && !typeSymbol.IsStatic)
- {
- var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
-
- if (
- viewEndpointInterfaceSymbol is not null
- && ImplementsInterface(typeSymbol, viewEndpointInterfaceSymbol)
- )
- {
- // Infer class name for deferred page name computation
- var className = typeSymbol.Name;
- if (className.EndsWith("Endpoint", StringComparison.Ordinal))
- className = className.Substring(
- 0,
- className.Length - "Endpoint".Length
- );
- else if (className.EndsWith("View", StringComparison.Ordinal))
- className = className.Substring(0, className.Length - "View".Length);
-
- var viewInfo = new ViewInfo
- {
- FullyQualifiedName = fqn,
- InferredClassName = className,
- Location = GetSourceLocation(typeSymbol),
- };
-
- var (viewRoute, _) = ReadRouteConstFields(typeSymbol);
- viewInfo.RouteTemplate = viewRoute;
- views.Add(viewInfo);
- }
- else if (ImplementsInterface(typeSymbol, endpointInterfaceSymbol))
- {
- var info = new EndpointInfo { FullyQualifiedName = fqn };
-
- foreach (var attr in typeSymbol.GetAttributes())
- {
- var attrName = attr.AttributeClass?.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- );
-
- if (
- attrName
- == "global::SimpleModule.Core.Authorization.RequirePermissionAttribute"
- )
- {
- if (attr.ConstructorArguments.Length > 0)
- {
- var arg = attr.ConstructorArguments[0];
- if (arg.Kind == TypedConstantKind.Array)
- {
- foreach (var val in arg.Values)
- {
- if (val.Value is string s)
- info.RequiredPermissions.Add(s);
- }
- }
- else if (arg.Value is string single)
- {
- info.RequiredPermissions.Add(single);
- }
- }
- }
- else if (
- attrName
- == "global::Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute"
- )
- {
- info.AllowAnonymous = true;
- }
- }
-
- var (epRoute, epMethod) = ReadRouteConstFields(typeSymbol);
- info.RouteTemplate = epRoute;
- info.HttpMethod = epMethod;
- endpoints.Add(info);
- }
- }
- }
- }
- }
-
- private static (string route, string method) ReadRouteConstFields(INamedTypeSymbol typeSymbol)
- {
- var route = "";
- var method = "";
- foreach (var m in typeSymbol.GetMembers())
- {
- if (m is IFieldSymbol { IsConst: true, ConstantValue: string value } field)
- {
- if (field.Name == "Route")
- route = value;
- else if (field.Name == "Method")
- method = value;
- }
- }
- return (route, method);
- }
-
- private static bool ImplementsInterface(
- INamedTypeSymbol typeSymbol,
- INamedTypeSymbol interfaceSymbol
- )
- {
- foreach (var iface in typeSymbol.AllInterfaces)
- {
- if (SymbolEqualityComparer.Default.Equals(iface, interfaceSymbol))
- return true;
- }
- return false;
- }
-
- private static void ScanModuleAssemblies(
- List modules,
- Dictionary moduleSymbols,
- Action action
- )
- {
- var scanned = new HashSet(SymbolEqualityComparer.Default);
- foreach (var module in modules)
- {
- if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol))
- continue;
-
- if (scanned.Add(typeSymbol.ContainingAssembly))
- action(typeSymbol.ContainingAssembly, module);
- }
- }
-
- private static bool DeclaresMethod(INamedTypeSymbol typeSymbol, string methodName)
- {
- foreach (var member in typeSymbol.GetMembers(methodName))
- {
- if (member is IMethodSymbol method)
- {
- // Source types: method has syntax in source code
- if (method.DeclaringSyntaxReferences.Length > 0)
- return true;
-
- // Metadata types: method exists in compiled IL (not synthesized)
- // IsImplicitlyDeclared filters out compiler-synthesized stubs for
- // default interface method dispatch
- if (
- !method.IsImplicitlyDeclared && method.Locations.Any(static l => l.IsInMetadata)
- )
- return true;
- }
- }
- return false;
- }
-
- private static void FindDtoTypes(
- INamespaceSymbol namespaceSymbol,
- INamedTypeSymbol dtoAttributeSymbol,
- List dtoTypes,
- CancellationToken cancellationToken
- )
- {
- foreach (var member in namespaceSymbol.GetMembers())
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- if (member is INamespaceSymbol childNamespace)
- {
- FindDtoTypes(childNamespace, dtoAttributeSymbol, dtoTypes, cancellationToken);
- }
- else if (member is INamedTypeSymbol typeSymbol)
- {
- foreach (var attr in typeSymbol.GetAttributes())
- {
- if (
- SymbolEqualityComparer.Default.Equals(
- attr.AttributeClass,
- dtoAttributeSymbol
- )
- )
- {
- var fqn = typeSymbol.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- );
- var safeName = TypeMappingHelpers.StripGlobalPrefix(fqn).Replace(".", "_");
-
- string? baseTypeFqn = null;
- if (
- typeSymbol.BaseType
- is { SpecialType: not SpecialType.System_Object }
- and var baseType
- )
- {
- var baseFqn = baseType.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- );
- if (
- baseType
- .GetAttributes()
- .Any(a =>
- SymbolEqualityComparer.Default.Equals(
- a.AttributeClass,
- dtoAttributeSymbol
- )
- )
- )
- {
- baseTypeFqn = baseFqn;
- }
- }
-
- dtoTypes.Add(
- new DtoTypeInfo
- {
- FullyQualifiedName = fqn,
- SafeName = safeName,
- BaseTypeFqn = baseTypeFqn,
- Properties = ExtractDtoProperties(typeSymbol),
- }
- );
- break;
- }
- }
- }
- }
- }
-
- private static bool InheritsFrom(INamedTypeSymbol typeSymbol, INamedTypeSymbol baseType)
- {
- var current = typeSymbol.BaseType;
- while (current is not null)
- {
- if (SymbolEqualityComparer.Default.Equals(current, baseType))
- return true;
- current = current.BaseType;
- }
- return false;
- }
-
- ///
- /// Reads [ContractLifetime(ServiceLifetime.X)] from the type.
- /// Returns 1 (Scoped) if the attribute is not present.
- /// ServiceLifetime: Singleton=0, Scoped=1, Transient=2
- ///
- private static int GetContractLifetime(INamedTypeSymbol typeSymbol)
- {
- foreach (var attr in typeSymbol.GetAttributes())
- {
- var attrName = attr.AttributeClass?.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- );
- if (
- attrName == "global::SimpleModule.Core.ContractLifetimeAttribute"
- && attr.ConstructorArguments.Length > 0
- && attr.ConstructorArguments[0].Value is int lifetime
- )
- {
- return lifetime;
- }
- }
- return 1; // Default: Scoped
- }
-
- private static bool HasDbContextConstructorParam(INamedTypeSymbol typeSymbol)
- {
- foreach (var ctor in typeSymbol.Constructors)
- {
- if (ctor.DeclaredAccessibility != Accessibility.Public || ctor.IsStatic)
- continue;
-
- foreach (var param in ctor.Parameters)
- {
- var paramType = param.Type;
- // Walk the base type chain to check for DbContext ancestry
- var current = paramType.BaseType;
- while (current != null)
- {
- var baseFqn = current.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- if (
- baseFqn == "global::Microsoft.EntityFrameworkCore.DbContext"
- || baseFqn.StartsWith(
- "global::Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext",
- StringComparison.Ordinal
- )
- )
- {
- return true;
- }
-
- current = current.BaseType;
- }
- }
- }
-
- return false;
- }
-
- private static string FindClosestModuleName(string typeFqn, List modules)
- {
- // Match by longest shared namespace prefix between the type and each module class.
- var bestMatch = "";
- var bestLength = -1;
- foreach (var module in modules)
- {
- var moduleFqn = TypeMappingHelpers.StripGlobalPrefix(module.FullyQualifiedName);
- var moduleNs = TypeMappingHelpers.ExtractNamespace(moduleFqn);
-
- if (
- typeFqn.StartsWith(moduleNs, StringComparison.Ordinal)
- && moduleNs.Length > bestLength
- )
- {
- bestLength = moduleNs.Length;
- bestMatch = module.ModuleName;
- }
- }
-
- return bestMatch.Length > 0 ? bestMatch : modules[0].ModuleName;
- }
-
- private static void FindDbContextTypes(
- INamespaceSymbol namespaceSymbol,
- string moduleName,
- List dbContexts,
- CancellationToken cancellationToken
- )
- {
- foreach (var member in namespaceSymbol.GetMembers())
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- if (member is INamespaceSymbol childNamespace)
- {
- FindDbContextTypes(childNamespace, moduleName, dbContexts, cancellationToken);
- }
- else if (
- member is INamedTypeSymbol typeSymbol
- && !typeSymbol.IsAbstract
- && !typeSymbol.IsStatic
- )
- {
- // Walk base type chain looking for DbContext
- var isDbContext = false;
- var isIdentity = false;
- string identityUserFqn = "";
- string identityRoleFqn = "";
- string identityKeyFqn = "";
-
- var current = typeSymbol.BaseType;
- while (current is not null)
- {
- var baseFqn = current.OriginalDefinition.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- );
-
- if (
- baseFqn
- == "global::Microsoft.AspNetCore.Identity.EntityFrameworkCore.IdentityDbContext"
- )
- {
- isDbContext = true;
- isIdentity = true;
- if (current.TypeArguments.Length >= 3)
- {
- identityUserFqn = current
- .TypeArguments[0]
- .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- identityRoleFqn = current
- .TypeArguments[1]
- .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- identityKeyFqn = current
- .TypeArguments[2]
- .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- }
- break;
- }
-
- if (baseFqn == "global::Microsoft.EntityFrameworkCore.DbContext")
- {
- isDbContext = true;
- break;
- }
-
- current = current.BaseType;
- }
-
- if (!isDbContext)
- continue;
-
- var info = new DbContextInfo
- {
- FullyQualifiedName = typeSymbol.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- ),
- ModuleName = moduleName,
- IsIdentityDbContext = isIdentity,
- IdentityUserTypeFqn = identityUserFqn,
- IdentityRoleTypeFqn = identityRoleFqn,
- IdentityKeyTypeFqn = identityKeyFqn,
- Location = GetSourceLocation(typeSymbol),
- };
-
- // Collect DbSet properties
- foreach (var m in typeSymbol.GetMembers())
- {
- if (
- m is IPropertySymbol prop
- && prop.DeclaredAccessibility == Accessibility.Public
- && !prop.IsStatic
- && prop.Type is INamedTypeSymbol propType
- && propType.IsGenericType
- && propType.OriginalDefinition.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- ) == "global::Microsoft.EntityFrameworkCore.DbSet"
- )
- {
- var entityType = propType.TypeArguments[0];
- var entityFqn = entityType.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- );
- var entityAssemblyName =
- entityType.ContainingAssembly?.Name ?? string.Empty;
- info.DbSets.Add(
- new DbSetInfo
- {
- PropertyName = prop.Name,
- EntityFqn = entityFqn,
- EntityAssemblyName = entityAssemblyName,
- EntityLocation = GetSourceLocation(entityType),
- }
- );
- }
- }
-
- dbContexts.Add(info);
- }
- }
- }
-
- private static void FindEntityConfigTypes(
- INamespaceSymbol namespaceSymbol,
- string moduleName,
- List entityConfigs,
- CancellationToken cancellationToken
- )
- {
- foreach (var member in namespaceSymbol.GetMembers())
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- if (member is INamespaceSymbol childNamespace)
- {
- FindEntityConfigTypes(childNamespace, moduleName, entityConfigs, cancellationToken);
- }
- else if (
- member is INamedTypeSymbol typeSymbol
- && !typeSymbol.IsAbstract
- && !typeSymbol.IsStatic
- )
- {
- foreach (var iface in typeSymbol.AllInterfaces)
- {
- if (
- iface.IsGenericType
- && iface.OriginalDefinition.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- )
- == "global::Microsoft.EntityFrameworkCore.IEntityTypeConfiguration"
- )
- {
- var entityFqn = iface
- .TypeArguments[0]
- .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- entityConfigs.Add(
- new EntityConfigInfo
- {
- ConfigFqn = typeSymbol.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- ),
- EntityFqn = entityFqn,
- ModuleName = moduleName,
- Location = GetSourceLocation(typeSymbol),
- }
- );
- break;
- }
- }
- }
- }
- }
-
- private static void ScanContractInterfaces(
- INamespaceSymbol namespaceSymbol,
- string assemblyName,
- List results
- )
- {
- foreach (var member in namespaceSymbol.GetMembers())
- {
- if (member is INamespaceSymbol childNs)
- {
- ScanContractInterfaces(childNs, assemblyName, results);
- }
- else if (
- member is INamedTypeSymbol typeSymbol
- && typeSymbol.TypeKind == TypeKind.Interface
- && typeSymbol.DeclaredAccessibility == Accessibility.Public
- )
- {
- var methodCount = 0;
- foreach (var m in typeSymbol.GetMembers())
- {
- if (m is IMethodSymbol ms && ms.MethodKind == MethodKind.Ordinary)
- methodCount++;
- }
-
- results.Add(
- new ContractInterfaceInfoRecord(
- assemblyName,
- typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
- methodCount,
- GetSourceLocation(typeSymbol)
- )
- );
- }
- }
- }
-
- private static void FindContractImplementations(
- INamespaceSymbol namespaceSymbol,
- HashSet contractInterfaceFqns,
- string moduleName,
- Compilation compilation,
- List results
- )
- {
- foreach (var member in namespaceSymbol.GetMembers())
- {
- if (member is INamespaceSymbol childNs)
- {
- FindContractImplementations(
- childNs,
- contractInterfaceFqns,
- moduleName,
- compilation,
- results
- );
- }
- else if (member is INamedTypeSymbol typeSymbol && typeSymbol.TypeKind == TypeKind.Class)
- {
- foreach (var iface in typeSymbol.AllInterfaces)
- {
- var ifaceFqn = iface.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- if (contractInterfaceFqns.Contains(ifaceFqn))
- {
- results.Add(
- new ContractImplementationInfo
- {
- InterfaceFqn = ifaceFqn,
- ImplementationFqn = typeSymbol.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- ),
- ModuleName = moduleName,
- IsPublic = typeSymbol.DeclaredAccessibility == Accessibility.Public,
- IsAbstract = typeSymbol.IsAbstract,
- DependsOnDbContext = HasDbContextConstructorParam(typeSymbol),
- Location = GetSourceLocation(typeSymbol),
- Lifetime = GetContractLifetime(typeSymbol),
- }
- );
- }
- }
- }
- }
- }
-
- private static void FindPermissionClasses(
- INamespaceSymbol namespaceSymbol,
- INamedTypeSymbol modulePermissionsSymbol,
- string moduleName,
- List results
- )
- {
- foreach (var member in namespaceSymbol.GetMembers())
- {
- if (member is INamespaceSymbol childNs)
- {
- FindPermissionClasses(childNs, modulePermissionsSymbol, moduleName, results);
- }
- else if (
- member is INamedTypeSymbol typeSymbol
- && typeSymbol.TypeKind == TypeKind.Class
- && ImplementsInterface(typeSymbol, modulePermissionsSymbol)
- )
- {
- var info = new PermissionClassInfo
- {
- FullyQualifiedName = typeSymbol.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- ),
- ModuleName = moduleName,
- IsSealed = typeSymbol.IsSealed,
- Location = GetSourceLocation(typeSymbol),
- };
-
- // Collect public const string fields
- foreach (var m in typeSymbol.GetMembers())
- {
- if (
- m is IFieldSymbol field
- && field.DeclaredAccessibility == Accessibility.Public
- )
- {
- info.Fields.Add(
- new PermissionFieldInfo
- {
- FieldName = field.Name,
- Value =
- field.HasConstantValue && field.ConstantValue is string s
- ? s
- : "",
- IsConstString =
- field.IsConst
- && field.Type.SpecialType == SpecialType.System_String,
- Location = GetSourceLocation(field),
- }
- );
- }
- }
-
- results.Add(info);
- }
- }
- }
-
- private static void FindFeatureClasses(
- INamespaceSymbol namespaceSymbol,
- INamedTypeSymbol moduleFeaturesSymbol,
- string moduleName,
- List results
- )
- {
- foreach (var member in namespaceSymbol.GetMembers())
- {
- if (member is INamespaceSymbol childNs)
- {
- FindFeatureClasses(childNs, moduleFeaturesSymbol, moduleName, results);
- }
- else if (
- member is INamedTypeSymbol typeSymbol
- && typeSymbol.TypeKind == TypeKind.Class
- && ImplementsInterface(typeSymbol, moduleFeaturesSymbol)
- )
- {
- var info = new FeatureClassInfo
- {
- FullyQualifiedName = typeSymbol.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- ),
- ModuleName = moduleName,
- IsSealed = typeSymbol.IsSealed,
- Location = GetSourceLocation(typeSymbol),
- };
-
- // Collect public const string fields
- foreach (var m in typeSymbol.GetMembers())
- {
- if (
- m is IFieldSymbol field
- && field.DeclaredAccessibility == Accessibility.Public
- )
- {
- info.Fields.Add(
- new FeatureFieldInfo
- {
- FieldName = field.Name,
- Value =
- field.HasConstantValue && field.ConstantValue is string s
- ? s
- : "",
- IsConstString =
- field.IsConst
- && field.Type.SpecialType == SpecialType.System_String,
- Location = GetSourceLocation(field),
- }
- );
- }
- }
-
- results.Add(info);
- }
- }
- }
-
- private static void FindModuleOptionsClasses(
- INamespaceSymbol namespaceSymbol,
- INamedTypeSymbol moduleOptionsSymbol,
- string moduleName,
- List results
- )
- {
- FindConcreteClassesImplementing(
- namespaceSymbol,
- moduleOptionsSymbol,
- typeSymbol =>
- results.Add(
- new ModuleOptionsRecord(
- typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
- moduleName,
- GetSourceLocation(typeSymbol)
- )
- )
+ return DiscoveryDataBuilder.Build(
+ modules,
+ dtoTypes,
+ dbContexts,
+ entityConfigs,
+ dependencies,
+ illegalReferences,
+ contractInterfaces,
+ contractImplementations,
+ permissionClasses,
+ featureClasses,
+ interceptors,
+ vogenValueObjects,
+ moduleOptionsList,
+ agentDefinitions,
+ agentToolProviders,
+ knowledgeSources,
+ contractsAssemblyMap,
+ s.HasAgentsAssembly,
+ hostAssemblyName
);
}
-
- ///
- /// 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)
- {
- 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,
- string moduleName,
- List results
- )
- {
- foreach (var member in namespaceSymbol.GetMembers())
- {
- if (member is INamespaceSymbol childNs)
- {
- FindInterceptorTypes(childNs, saveChangesInterceptorSymbol, moduleName, results);
- }
- else if (
- member is INamedTypeSymbol typeSymbol
- && typeSymbol.TypeKind == TypeKind.Class
- && !typeSymbol.IsAbstract
- && !typeSymbol.IsStatic
- && ImplementsInterface(typeSymbol, saveChangesInterceptorSymbol)
- )
- {
- var info = new InterceptorInfo
- {
- FullyQualifiedName = typeSymbol.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- ),
- ModuleName = moduleName,
- Location = GetSourceLocation(typeSymbol),
- };
-
- // Extract constructor parameter type FQNs
- foreach (var ctor in typeSymbol.Constructors)
- {
- if (ctor.DeclaredAccessibility != Accessibility.Public)
- continue;
-
- foreach (var param in ctor.Parameters)
- {
- info.ConstructorParamTypeFqns.Add(
- param.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
- );
- }
-
- // Only process the first public constructor
- break;
- }
-
- results.Add(info);
- }
- }
- }
-
- private static void FindConventionDtoTypes(
- INamespaceSymbol namespaceSymbol,
- INamedTypeSymbol? noDtoAttrSymbol,
- INamedTypeSymbol? eventInterfaceSymbol,
- HashSet existingFqns,
- List dtoTypes,
- CancellationToken cancellationToken
- )
- {
- foreach (var member in namespaceSymbol.GetMembers())
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- if (member is INamespaceSymbol childNs)
- {
- FindConventionDtoTypes(
- childNs,
- noDtoAttrSymbol,
- eventInterfaceSymbol,
- existingFqns,
- dtoTypes,
- cancellationToken
- );
- }
- else if (
- member is INamedTypeSymbol typeSymbol
- && typeSymbol.DeclaredAccessibility == Accessibility.Public
- && !typeSymbol.IsStatic
- && typeSymbol.TypeKind != TypeKind.Interface
- && typeSymbol.TypeKind != TypeKind.Enum
- && typeSymbol.TypeKind != TypeKind.Delegate
- )
- {
- var fqn = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
-
- // Skip if already found via [Dto]
- if (existingFqns.Contains(fqn))
- continue;
-
- // Skip if [NoDtoGeneration]
- if (noDtoAttrSymbol is not null)
- {
- var hasNoDtoAttr = false;
- foreach (var attr in typeSymbol.GetAttributes())
- {
- if (
- SymbolEqualityComparer.Default.Equals(
- attr.AttributeClass,
- noDtoAttrSymbol
- )
- )
- {
- hasNoDtoAttr = true;
- break;
- }
- }
- if (hasNoDtoAttr)
- continue;
- }
-
- // Skip types that implement IEvent (events are not DTOs)
- if (eventInterfaceSymbol is not null)
- {
- var isEvent = false;
- foreach (var iface in typeSymbol.AllInterfaces)
- {
- if (SymbolEqualityComparer.Default.Equals(iface, eventInterfaceSymbol))
- {
- isEvent = true;
- break;
- }
- }
- if (isEvent)
- continue;
- }
-
- // Skip generic type definitions (open generics like PagedResult)
- if (typeSymbol.IsGenericType)
- continue;
-
- // Skip Vogen-generated infrastructure types
- if (
- typeSymbol.Name == "VogenTypesFactory"
- || fqn.StartsWith("global::Vogen", StringComparison.Ordinal)
- )
- continue;
-
- // Skip Vogen value objects — they have their own JsonConverter
- // and must not be treated as regular DTOs in the JSON resolver
- if (IsVogenValueObject(typeSymbol))
- continue;
-
- var safeName = TypeMappingHelpers.StripGlobalPrefix(fqn).Replace(".", "_");
-
- string? baseTypeFqn = null;
- if (
- typeSymbol.BaseType
- is { SpecialType: not SpecialType.System_Object }
- and var baseType
- )
- {
- var baseFqn = baseType.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- );
- if (existingFqns.Contains(baseFqn))
- {
- baseTypeFqn = baseFqn;
- }
- }
-
- existingFqns.Add(fqn);
- dtoTypes.Add(
- new DtoTypeInfo
- {
- FullyQualifiedName = fqn,
- SafeName = safeName,
- BaseTypeFqn = baseTypeFqn,
- Properties = ExtractDtoProperties(typeSymbol),
- }
- );
- }
- }
- }
-
- private static List ExtractDtoProperties(INamedTypeSymbol typeSymbol)
- {
- // Walk the inheritance chain (most-derived first) so derived properties shadow
- // base properties of the same name. This lets DTOs inherit shared base classes
- // (e.g., AuditableEntity -> Id, CreatedAt, UpdatedAt, ConcurrencyStamp)
- // and still get serialized correctly.
- var seen = new HashSet(StringComparer.Ordinal);
- var properties = new List();
- for (
- var current = typeSymbol;
- current is not null && current.SpecialType != SpecialType.System_Object;
- current = current.BaseType
- )
- {
- foreach (var m in current.GetMembers())
- {
- if (
- m is IPropertySymbol prop
- && prop.DeclaredAccessibility == Accessibility.Public
- && !prop.IsStatic
- && !prop.IsIndexer
- && prop.GetMethod is not null
- && !HasJsonIgnoreAttribute(prop)
- && seen.Add(prop.Name)
- )
- {
- var resolvedType = ResolveUnderlyingType(prop.Type);
- var actualType = prop.Type.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- );
- properties.Add(
- new DtoPropertyInfo
- {
- Name = prop.Name,
- TypeFqn = actualType,
- UnderlyingTypeFqn = resolvedType != actualType ? resolvedType : null,
- HasSetter =
- prop.SetMethod is not null
- && prop.SetMethod.DeclaredAccessibility == Accessibility.Public,
- }
- );
- }
- }
- }
- return properties;
- }
-
- ///
- /// Returns true if the property is decorated with [System.Text.Json.Serialization.JsonIgnore].
- /// Properties marked this way are excluded from generated JSON metadata, mirroring
- /// runtime System.Text.Json behavior.
- ///
- private static bool HasJsonIgnoreAttribute(IPropertySymbol prop)
- {
- foreach (var attr in prop.GetAttributes())
- {
- var name = attr.AttributeClass?.ToDisplayString();
- if (name == "System.Text.Json.Serialization.JsonIgnoreAttribute")
- {
- return true;
- }
- }
- return false;
- }
-
- private static void FindVogenValueObjectsWithEfConverters(
- INamespaceSymbol ns,
- List results
- )
- {
- foreach (var type in ns.GetTypeMembers())
- {
- if (!IsVogenValueObject(type))
- continue;
-
- var converterMembers = type.GetTypeMembers("EfCoreValueConverter");
- var comparerMembers = type.GetTypeMembers("EfCoreValueComparer");
-
- if (converterMembers.Length == 0 || comparerMembers.Length == 0)
- continue;
-
- var typeFqn = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- var converterFqn = converterMembers[0]
- .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- var comparerFqn = comparerMembers[0]
- .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
-
- results.Add(new VogenValueObjectRecord(typeFqn, converterFqn, comparerFqn));
- }
-
- foreach (var childNs in ns.GetNamespaceMembers())
- {
- FindVogenValueObjectsWithEfConverters(childNs, results);
- }
- }
-
- private static bool IsVogenValueObject(INamedTypeSymbol typeSymbol)
- {
- foreach (var attr in typeSymbol.GetAttributes())
- {
- var attrClass = attr.AttributeClass;
- if (
- attrClass is not null
- && attrClass.IsGenericType
- && attrClass.Name == "ValueObjectAttribute"
- && attrClass.ContainingNamespace.ToDisplayString() == "Vogen"
- )
- {
- return true;
- }
- }
-
- return false;
- }
-
- ///
- /// If the type is a Vogen value object, returns the FQN of its underlying primitive type.
- /// Otherwise returns the type's own FQN.
- ///
- private static string ResolveUnderlyingType(ITypeSymbol typeSymbol)
- {
- foreach (var attr in typeSymbol.GetAttributes())
- {
- var attrClass = attr.AttributeClass;
- if (attrClass is null)
- continue;
-
- // Vogen uses generic attribute ValueObjectAttribute
- if (
- attrClass.IsGenericType
- && attrClass.Name == "ValueObjectAttribute"
- && attrClass.ContainingNamespace.ToDisplayString() == "Vogen"
- && attrClass.TypeArguments.Length == 1
- )
- {
- return attrClass
- .TypeArguments[0]
- .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- }
- }
-
- return typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- }
-
- private static void FindImplementors(
- INamespaceSymbol namespaceSymbol,
- INamedTypeSymbol interfaceSymbol,
- string moduleName,
- List results
- )
- {
- foreach (var member in namespaceSymbol.GetMembers())
- {
- if (member is INamespaceSymbol childNamespace)
- {
- FindImplementors(childNamespace, interfaceSymbol, moduleName, results);
- }
- else if (
- member is INamedTypeSymbol typeSymbol
- && !typeSymbol.IsAbstract
- && typeSymbol.TypeKind == TypeKind.Class
- && ImplementsInterface(typeSymbol, interfaceSymbol)
- )
- {
- results.Add(
- new DiscoveredTypeInfo
- {
- FullyQualifiedName = typeSymbol.ToDisplayString(
- SymbolDisplayFormat.FullyQualifiedFormat
- ),
- ModuleName = moduleName,
- }
- );
- }
- }
- }
}
diff --git a/framework/SimpleModule.Generator/Discovery/SymbolHelpers.cs b/framework/SimpleModule.Generator/Discovery/SymbolHelpers.cs
new file mode 100644
index 00000000..34cbb251
--- /dev/null
+++ b/framework/SimpleModule.Generator/Discovery/SymbolHelpers.cs
@@ -0,0 +1,165 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+
+namespace SimpleModule.Generator;
+
+internal static class SymbolHelpers
+{
+ ///
+ /// Extracts a serializable source location from a symbol, if available.
+ /// Returns null for symbols only available in metadata (compiled DLLs).
+ ///
+ internal static SourceLocationRecord? GetSourceLocation(ISymbol symbol)
+ {
+ foreach (var loc in symbol.Locations)
+ {
+ if (loc.IsInSource)
+ {
+ var span = loc.GetLineSpan();
+ return new SourceLocationRecord(
+ span.Path,
+ span.StartLinePosition.Line,
+ span.StartLinePosition.Character,
+ span.EndLinePosition.Line,
+ span.EndLinePosition.Character
+ );
+ }
+ }
+ return null;
+ }
+
+ internal static bool ImplementsInterface(
+ INamedTypeSymbol typeSymbol,
+ INamedTypeSymbol interfaceSymbol
+ )
+ {
+ foreach (var iface in typeSymbol.AllInterfaces)
+ {
+ if (SymbolEqualityComparer.Default.Equals(iface, interfaceSymbol))
+ return true;
+ }
+ return false;
+ }
+
+ internal static bool InheritsFrom(INamedTypeSymbol typeSymbol, INamedTypeSymbol baseType)
+ {
+ var current = typeSymbol.BaseType;
+ while (current is not null)
+ {
+ if (SymbolEqualityComparer.Default.Equals(current, baseType))
+ return true;
+ current = current.BaseType;
+ }
+ return false;
+ }
+
+ internal static bool DeclaresMethod(INamedTypeSymbol typeSymbol, string methodName)
+ {
+ foreach (var member in typeSymbol.GetMembers(methodName))
+ {
+ if (member is IMethodSymbol method)
+ {
+ if (method.DeclaringSyntaxReferences.Length > 0)
+ return true;
+ if (
+ !method.IsImplicitlyDeclared && method.Locations.Any(static l => l.IsInMetadata)
+ )
+ return true;
+ }
+ }
+ return false;
+ }
+
+ internal static void ScanModuleAssemblies(
+ List modules,
+ Dictionary moduleSymbols,
+ Action action
+ )
+ {
+ var scanned = new HashSet(SymbolEqualityComparer.Default);
+ foreach (var module in modules)
+ {
+ if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol))
+ continue;
+
+ if (scanned.Add(typeSymbol.ContainingAssembly))
+ action(typeSymbol.ContainingAssembly, module);
+ }
+ }
+
+ ///
+ /// Pre-computed namespace → module-name index used by
+ /// . Entries are sorted by namespace
+ /// length descending so the first StartsWith match wins (longest
+ /// namespace wins over shorter prefix matches).
+ ///
+ internal readonly struct ModuleNamespaceIndex
+ {
+ internal readonly (string Namespace, string ModuleName)[] Entries;
+ internal readonly string FirstModuleName;
+
+ internal ModuleNamespaceIndex(
+ (string Namespace, string ModuleName)[] entries,
+ string firstModuleName
+ )
+ {
+ Entries = entries;
+ FirstModuleName = firstModuleName;
+ }
+ }
+
+ internal static ModuleNamespaceIndex BuildModuleNamespaceIndex(List modules)
+ {
+ var entries = new (string Namespace, string ModuleName)[modules.Count];
+ for (var i = 0; i < modules.Count; i++)
+ {
+ var moduleFqn = TypeMappingHelpers.StripGlobalPrefix(modules[i].FullyQualifiedName);
+ entries[i] = (TypeMappingHelpers.ExtractNamespace(moduleFqn), modules[i].ModuleName);
+ }
+
+ System.Array.Sort(entries, (a, b) => b.Namespace.Length.CompareTo(a.Namespace.Length));
+
+ return new ModuleNamespaceIndex(entries, modules[0].ModuleName);
+ }
+
+ internal static string FindClosestModuleNameFast(string typeFqn, ModuleNamespaceIndex index)
+ {
+ foreach (var (ns, moduleName) in index.Entries)
+ {
+ if (typeFqn.StartsWith(ns, StringComparison.Ordinal))
+ return moduleName;
+ }
+ return index.FirstModuleName;
+ }
+
+ ///
+ /// Recursively walks namespaces and invokes for each
+ /// concrete (non-abstract, non-static) class that implements the given interface.
+ ///
+ internal static void FindConcreteClassesImplementing(
+ INamespaceSymbol namespaceSymbol,
+ INamedTypeSymbol interfaceSymbol,
+ Action 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);
+ }
+ }
+ }
+}
diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
index caaad47d..6a2669f9 100644
--- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
+++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs
@@ -1,1294 +1,16 @@
-using System;
-using System.Collections.Generic;
-using System.Collections.Immutable;
-using System.IO;
-using System.Linq;
using Microsoft.CodeAnalysis;
namespace SimpleModule.Generator;
-///
-/// Naming conventions for SimpleModule assemblies. Centralised so the same
-/// string literals don't drift between discovery code and diagnostic emission.
-///
-internal static class AssemblyConventions
-{
- internal const string FrameworkPrefix = "SimpleModule.";
- internal const string ContractsSuffix = ".Contracts";
- internal const string ModuleSuffix = ".Module";
-
- ///
- /// Derives the `.Contracts` sibling assembly name for a SimpleModule
- /// implementation assembly. Strips a trailing .Module suffix first
- /// so SimpleModule.Agents.Module maps to
- /// SimpleModule.Agents.Contracts instead of
- /// SimpleModule.Agents.Module.Contracts.
- ///
- internal static string GetExpectedContractsAssemblyName(string implementationAssemblyName)
- {
- var baseName = implementationAssemblyName.EndsWith(ModuleSuffix, StringComparison.Ordinal)
- ? implementationAssemblyName.Substring(
- 0,
- implementationAssemblyName.Length - ModuleSuffix.Length
- )
- : implementationAssemblyName;
- return baseName + ContractsSuffix;
- }
-}
-
internal sealed class DiagnosticEmitter : IEmitter
{
- internal static readonly DiagnosticDescriptor DuplicateDbSetPropertyName = new(
- id: "SM0001",
- title: "Duplicate DbSet property name across modules",
- messageFormat: "DbSet property name '{0}' is used by multiple modules: {1} (entity {2}) and {3} (entity {4}). Each module must use unique DbSet property names to avoid table name conflicts in the unified HostDbContext.",
- category: "SimpleModule.Generator",
- defaultSeverity: DiagnosticSeverity.Error,
- isEnabledByDefault: true
- );
-
- private static readonly DiagnosticDescriptor EmptyModuleName = new(
- id: "SM0002",
- title: "Module has empty name",
- messageFormat: "Module class '{0}' has an empty [Module] name. Provide a non-empty name: [Module(\"MyModule\")]. An empty name will cause broken route prefixes, schema names, and TypeScript module grouping.",
- category: "SimpleModule.Generator",
- defaultSeverity: DiagnosticSeverity.Warning,
- isEnabledByDefault: true
- );
-
- private static readonly DiagnosticDescriptor MultipleIdentityDbContexts = new(
- id: "SM0003",
- title: "Multiple IdentityDbContext types found",
- messageFormat: "Multiple modules define an IdentityDbContext: '{0}' (module {1}) and '{2}' (module {3}). Only one module should provide Identity. The unified HostDbContext can only extend one IdentityDbContext base class.",
- category: "SimpleModule.Generator",
- defaultSeverity: DiagnosticSeverity.Error,
- isEnabledByDefault: true
- );
-
- private static readonly DiagnosticDescriptor IdentityDbContextBadTypeArgs = new(
- id: "SM0005",
- title: "IdentityDbContext has unexpected type arguments",
- messageFormat: "IdentityDbContext '{0}' in module '{1}' must extend IdentityDbContext with exactly 3 type arguments, but {2} were found. Use the 3-argument form: IdentityDbContext.",
- category: "SimpleModule.Generator",
- defaultSeverity: DiagnosticSeverity.Error,
- isEnabledByDefault: true
- );
-
- private static readonly DiagnosticDescriptor EntityConfigForMissingEntity = new(
- id: "SM0006",
- title: "Entity configuration targets entity not in any DbSet",
- messageFormat: "IEntityTypeConfiguration<{0}> in '{1}' (module '{2}') configures an entity that is not exposed as a DbSet in any module's DbContext. Add a DbSet<{0}> property to a DbContext, or remove this configuration.",
- category: "SimpleModule.Generator",
- defaultSeverity: DiagnosticSeverity.Warning,
- isEnabledByDefault: true
- );
-
- private static readonly DiagnosticDescriptor DuplicateEntityConfiguration = new(
- id: "SM0007",
- title: "Duplicate entity configuration",
- messageFormat: "Entity '{0}' has multiple IEntityTypeConfiguration implementations: '{1}' and '{2}'. EF Core only supports one configuration per entity type. Remove the duplicate.",
- category: "SimpleModule.Generator",
- defaultSeverity: DiagnosticSeverity.Error,
- isEnabledByDefault: true
- );
-
- internal static readonly DiagnosticDescriptor CircularModuleDependency = new(
- id: "SM0010",
- title: "Circular module dependency detected",
- messageFormat: "Circular module dependency detected. Cycle: {0}. {1}To break this cycle, identify which direction is the primary dependency and reverse the other using IEventBus. For example, if {2} is the primary consumer of {3}: (1) Keep {2} \u2192 {3}.Contracts. (2) Remove {3} \u2192 {2}.Contracts. (3) In {3}, publish events via IEventBus instead of calling {2} directly. (4) In {2}, implement IEventHandler