From 910072be1b9f413eeb73d06a784f32bc0330b0f3 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 19:09:56 +0200 Subject: [PATCH 01/38] Add spec: source generator split & safe perf wins --- ...6-04-15-generator-split-and-perf-design.md | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-15-generator-split-and-perf-design.md 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. From 1b3126f73445e6dff8768ac695f75923b74059fb Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 19:20:04 +0200 Subject: [PATCH 02/38] Add implementation plan: source generator split & safe perf wins --- .../2026-04-15-generator-split-and-perf.md | 2775 +++++++++++++++++ 1 file changed, 2775 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-15-generator-split-and-perf.md 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. From 64dbc11dabc1f6fa9fa349412fa417b963449343 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 19:37:14 +0200 Subject: [PATCH 03/38] chore(generator): ignore baseline snapshot dir used during refactor --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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/ From 3c5ef03173e9489ee7b40c5fec9ec40e3bd9f9af Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 19:38:54 +0200 Subject: [PATCH 04/38] refactor(generator): move AssemblyConventions to Discovery namespace --- .../Discovery/AssemblyConventions.cs | 32 +++++++++++++++++++ .../Emitters/DiagnosticEmitter.cs | 29 ----------------- 2 files changed, 32 insertions(+), 29 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/AssemblyConventions.cs 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/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index caaad47d..0e3c556c 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -7,35 +7,6 @@ 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( From b523dcb23fdb844f5bcd7902d008b6a48359a9de Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 19:40:15 +0200 Subject: [PATCH 05/38] feat(generator): add CoreSymbols record for one-shot type resolution --- .../Discovery/CoreSymbols.cs | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 framework/SimpleModule.Generator/Discovery/CoreSymbols.cs 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 + ); + } +} From 03f21ed1e9a475a0138365ef18746b74978a878f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 19:46:47 +0200 Subject: [PATCH 06/38] perf(generator): resolve core symbols once via CoreSymbols record Replace the 17 scattered GetTypeByMetadataName calls at the top and throughout Extract with a single CoreSymbols.TryResolve call, then thread the resolved symbols through downstream finder invocations via the s.Field naming convention. --- .../Discovery/SymbolDiscovery.cs | 132 ++++++------------ 1 file changed, 39 insertions(+), 93 deletions(-) diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 14abe3db..4947b2cd 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -39,45 +39,10 @@ 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 dtoAttributeSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.DtoAttribute" - ); - - var endpointInterfaceSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.IEndpoint" - ); - - var viewEndpointInterfaceSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.IViewEndpoint" - ); - - var agentDefinitionSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.Agents.IAgentDefinition" - ); - var agentToolProviderSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.Agents.IAgentToolProvider" - ); - var knowledgeSourceSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.Rag.IKnowledgeSource" - ); - - // 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" - ); + var s = symbols.Value; var modules = new List(); @@ -93,11 +58,11 @@ is not IAssemblySymbol assemblySymbol FindModuleTypes( assemblySymbol.GlobalNamespace, - moduleAttributeSymbol, - moduleServicesSymbol, - moduleMenuSymbol, - moduleMiddlewareSymbol, - moduleSettingsSymbol, + s.ModuleAttribute, + s.ModuleServices, + s.ModuleMenu, + s.ModuleMiddleware, + s.ModuleSettings, modules, cancellationToken ); @@ -105,11 +70,11 @@ is not IAssemblySymbol assemblySymbol FindModuleTypes( compilation.Assembly.GlobalNamespace, - moduleAttributeSymbol, - moduleServicesSymbol, - moduleMenuSymbol, - moduleMiddlewareSymbol, - moduleSettingsSymbol, + s.ModuleAttribute, + s.ModuleServices, + s.ModuleMenu, + s.ModuleMiddleware, + s.ModuleSettings, modules, cancellationToken ); @@ -130,7 +95,7 @@ is not IAssemblySymbol assemblySymbol // 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) + if (s.EndpointInterface is not null) { var endpointScannedAssemblies = new HashSet( SymbolEqualityComparer.Default @@ -150,8 +115,8 @@ is not IAssemblySymbol assemblySymbol var rawViews = new List(); FindEndpointTypes( assembly.GlobalNamespace, - endpointInterfaceSymbol, - viewEndpointInterfaceSymbol, + s.EndpointInterface, + s.ViewEndpointInterface, rawEndpoints, rawViews, cancellationToken @@ -267,7 +232,7 @@ is not IAssemblySymbol assemblySymbol } var dtoTypes = new List(); - if (dtoAttributeSymbol is not null) + if (s.DtoAttribute is not null) { foreach (var reference in compilation.References) { @@ -281,7 +246,7 @@ is not IAssemblySymbol assemblySymbol FindDtoTypes( assemblySymbol.GlobalNamespace, - dtoAttributeSymbol, + s.DtoAttribute, dtoTypes, cancellationToken ); @@ -289,7 +254,7 @@ is not IAssemblySymbol assemblySymbol FindDtoTypes( compilation.Assembly.GlobalNamespace, - dtoAttributeSymbol, + s.DtoAttribute, dtoTypes, cancellationToken ); @@ -351,12 +316,6 @@ is not IAssemblySymbol assemblySymbol } // 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); @@ -366,8 +325,8 @@ is not IAssemblySymbol assemblySymbol cancellationToken.ThrowIfCancellationRequested(); FindConventionDtoTypes( kvp.Value.GlobalNamespace, - noDtoAttrSymbol, - eventInterfaceSymbol, + s.NoDtoAttribute, + s.EventInterface, existingDtoFqns, dtoTypes, cancellationToken @@ -417,10 +376,7 @@ is not IAssemblySymbol assemblySymbol // Step 3c: Find IModulePermissions implementors in module and contracts assemblies var permissionClasses = new List(); - var modulePermissionsSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.Authorization.IModulePermissions" - ); - if (modulePermissionsSymbol is not null) + if (s.ModulePermissions is not null) { foreach (var module in modules) { @@ -430,7 +386,7 @@ is not IAssemblySymbol assemblySymbol var moduleAssembly = typeSymbol.ContainingAssembly; FindPermissionClasses( moduleAssembly.GlobalNamespace, - modulePermissionsSymbol, + s.ModulePermissions, module.ModuleName, permissionClasses ); @@ -443,7 +399,7 @@ is not IAssemblySymbol assemblySymbol { FindPermissionClasses( kvp.Value.GlobalNamespace, - modulePermissionsSymbol, + s.ModulePermissions, moduleName, permissionClasses ); @@ -453,10 +409,7 @@ is not IAssemblySymbol assemblySymbol // Step 3d: Find IModuleFeatures implementors in module and contracts assemblies var featureClasses = new List(); - var moduleFeaturesSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.FeatureFlags.IModuleFeatures" - ); - if (moduleFeaturesSymbol is not null) + if (s.ModuleFeatures is not null) { foreach (var module in modules) { @@ -466,7 +419,7 @@ is not IAssemblySymbol assemblySymbol var moduleAssembly = typeSymbol.ContainingAssembly; FindFeatureClasses( moduleAssembly.GlobalNamespace, - moduleFeaturesSymbol, + s.ModuleFeatures, module.ModuleName, featureClasses ); @@ -479,7 +432,7 @@ is not IAssemblySymbol assemblySymbol { FindFeatureClasses( kvp.Value.GlobalNamespace, - moduleFeaturesSymbol, + s.ModuleFeatures, moduleName, featureClasses ); @@ -489,10 +442,7 @@ is not IAssemblySymbol assemblySymbol // Step 3e: Find ISaveChangesInterceptor implementors in module assemblies var interceptors = new List(); - var saveChangesInterceptorSymbol = compilation.GetTypeByMetadataName( - "Microsoft.EntityFrameworkCore.Diagnostics.ISaveChangesInterceptor" - ); - if (saveChangesInterceptorSymbol is not null) + if (s.SaveChangesInterceptor is not null) { ScanModuleAssemblies( modules, @@ -501,7 +451,7 @@ is not IAssemblySymbol assemblySymbol { FindInterceptorTypes( assembly.GlobalNamespace, - saveChangesInterceptorSymbol, + s.SaveChangesInterceptor, module.ModuleName, interceptors ); @@ -539,10 +489,7 @@ is not IAssemblySymbol assemblySymbol // Step 3f: Find IModuleOptions implementors in module and contracts assemblies var moduleOptionsList = new List(); - var moduleOptionsSymbol = compilation.GetTypeByMetadataName( - "SimpleModule.Core.IModuleOptions" - ); - if (moduleOptionsSymbol is not null) + if (s.ModuleOptions is not null) { ScanModuleAssemblies( modules, @@ -550,7 +497,7 @@ is not IAssemblySymbol assemblySymbol (assembly, module) => FindModuleOptionsClasses( assembly.GlobalNamespace, - moduleOptionsSymbol, + s.ModuleOptions, module.ModuleName, moduleOptionsList ) @@ -563,7 +510,7 @@ is not IAssemblySymbol assemblySymbol { FindModuleOptionsClasses( kvp.Value.GlobalNamespace, - moduleOptionsSymbol, + s.ModuleOptions, moduleName, moduleOptionsList ); @@ -576,7 +523,7 @@ is not IAssemblySymbol assemblySymbol var agentToolProviders = new List(); var knowledgeSources = new List(); - if (agentDefinitionSymbol is not null) + if (s.AgentDefinition is not null) { ScanModuleAssemblies( modules, @@ -584,14 +531,14 @@ is not IAssemblySymbol assemblySymbol (assembly, module) => FindImplementors( assembly.GlobalNamespace, - agentDefinitionSymbol, + s.AgentDefinition, module.ModuleName, agentDefinitions ) ); } - if (agentToolProviderSymbol is not null) + if (s.AgentToolProvider is not null) { ScanModuleAssemblies( modules, @@ -599,14 +546,14 @@ is not IAssemblySymbol assemblySymbol (assembly, module) => FindImplementors( assembly.GlobalNamespace, - agentToolProviderSymbol, + s.AgentToolProvider, module.ModuleName, agentToolProviders ) ); } - if (knowledgeSourceSymbol is not null) + if (s.KnowledgeSource is not null) { ScanModuleAssemblies( modules, @@ -614,7 +561,7 @@ is not IAssemblySymbol assemblySymbol (assembly, module) => FindImplementors( assembly.GlobalNamespace, - knowledgeSourceSymbol, + s.KnowledgeSource, module.ModuleName, knowledgeSources ) @@ -818,8 +765,7 @@ is not IAssemblySymbol assemblySymbol .Select(k => new KnowledgeSourceRecord(k.FullyQualifiedName, k.ModuleName)) .ToImmutableArray(), contractsAssemblyMap.Keys.ToImmutableArray(), - compilation.GetTypeByMetadataName("SimpleModule.Agents.SimpleModuleAgentExtensions") - is not null, + s.HasAgentsAssembly, hostAssemblyName ); } From d352f8677d1acba3afaa2f1803c4dd8306a41124 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 19:50:09 +0200 Subject: [PATCH 07/38] refactor(generator): split ModuleRecords out of DiscoveryData --- .../Discovery/DiscoveryData.cs | 115 ----------------- .../Discovery/Records/ModuleRecords.cs | 119 ++++++++++++++++++ 2 files changed, 119 insertions(+), 115 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs index b01d6d81..fc26041e 100644 --- a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs @@ -132,107 +132,6 @@ 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, @@ -318,20 +217,6 @@ internal readonly record struct EntityConfigInfoRecord( 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, 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 +); From a1c315f2260059c6bea292f5cdd44a08770e5292 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 19:52:55 +0200 Subject: [PATCH 08/38] refactor(generator): split DataRecords out of DiscoveryData, trim top file to 135 lines --- .../Discovery/DiscoveryData.cs | 392 +----------------- .../Discovery/Records/DataRecords.cs | 391 +++++++++++++++++ 2 files changed, 392 insertions(+), 391 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/Records/DataRecords.cs diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs index fc26041e..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,394 +132,4 @@ public override int GetHashCode() } } -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); - -#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/Records/DataRecords.cs b/framework/SimpleModule.Generator/Discovery/Records/DataRecords.cs new file mode 100644 index 00000000..d4373c4c --- /dev/null +++ b/framework/SimpleModule.Generator/Discovery/Records/DataRecords.cs @@ -0,0 +1,391 @@ +using System.Collections.Generic; +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); + +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; } = ""; +} From be28c29964c0b0b9b876ed9d1d65f0c7a8d1d8e5 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 19:56:40 +0200 Subject: [PATCH 09/38] refactor(generator): extract SymbolHelpers from SymbolDiscovery --- .../Discovery/SymbolDiscovery.cs | 243 +++++------------- .../Discovery/SymbolHelpers.cs | 143 +++++++++++ 2 files changed, 204 insertions(+), 182 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/SymbolHelpers.cs diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 4947b2cd..60cb9647 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -9,29 +9,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 @@ -126,7 +103,7 @@ is not IAssemblySymbol assemblySymbol foreach (var ep in rawEndpoints) { var epFqn = TypeMappingHelpers.StripGlobalPrefix(ep.FullyQualifiedName); - var ownerName = FindClosestModuleName(epFqn, modules); + var ownerName = SymbolHelpers.FindClosestModuleName(epFqn, modules); var owner = modules.Find(m => m.ModuleName == ownerName); if (owner is not null) owner.Endpoints.Add(ep); @@ -146,7 +123,7 @@ is not IAssemblySymbol assemblySymbol foreach (var v in rawViews) { var vFqn = TypeMappingHelpers.StripGlobalPrefix(v.FullyQualifiedName); - var ownerName = FindClosestModuleName(vFqn, modules); + var ownerName = SymbolHelpers.FindClosestModuleName(vFqn, modules); var owner = modules.Find(m => m.ModuleName == ownerName); if (owner is not null) { @@ -219,14 +196,14 @@ is not IAssemblySymbol assemblySymbol foreach (var ctx in rawDbContexts) { var ctxNs = TypeMappingHelpers.StripGlobalPrefix(ctx.FullyQualifiedName); - ctx.ModuleName = FindClosestModuleName(ctxNs, modules); + ctx.ModuleName = SymbolHelpers.FindClosestModuleName(ctxNs, modules); dbContexts.Add(ctx); } foreach (var cfg in rawEntityConfigs) { var cfgNs = TypeMappingHelpers.StripGlobalPrefix(cfg.ConfigFqn); - cfg.ModuleName = FindClosestModuleName(cfgNs, modules); + cfg.ModuleName = SymbolHelpers.FindClosestModuleName(cfgNs, modules); entityConfigs.Add(cfg); } } @@ -444,7 +421,7 @@ is not IAssemblySymbol assemblySymbol var interceptors = new List(); if (s.SaveChangesInterceptor is not null) { - ScanModuleAssemblies( + SymbolHelpers.ScanModuleAssemblies( modules, moduleSymbols, (assembly, module) => @@ -472,7 +449,7 @@ is not IAssemblySymbol assemblySymbol } } - ScanModuleAssemblies( + SymbolHelpers.ScanModuleAssemblies( modules, moduleSymbols, (assembly, _) => @@ -491,7 +468,7 @@ is not IAssemblySymbol assemblySymbol var moduleOptionsList = new List(); if (s.ModuleOptions is not null) { - ScanModuleAssemblies( + SymbolHelpers.ScanModuleAssemblies( modules, moduleSymbols, (assembly, module) => @@ -525,7 +502,7 @@ is not IAssemblySymbol assemblySymbol if (s.AgentDefinition is not null) { - ScanModuleAssemblies( + SymbolHelpers.ScanModuleAssemblies( modules, moduleSymbols, (assembly, module) => @@ -540,7 +517,7 @@ is not IAssemblySymbol assemblySymbol if (s.AgentToolProvider is not null) { - ScanModuleAssemblies( + SymbolHelpers.ScanModuleAssemblies( modules, moduleSymbols, (assembly, module) => @@ -555,7 +532,7 @@ is not IAssemblySymbol assemblySymbol if (s.KnowledgeSource is not null) { - ScanModuleAssemblies( + SymbolHelpers.ScanModuleAssemblies( modules, moduleSymbols, (assembly, module) => @@ -841,50 +818,65 @@ CancellationToken cancellationToken ), ModuleName = moduleName, HasConfigureServices = - DeclaresMethod(typeSymbol, "ConfigureServices") + SymbolHelpers.DeclaresMethod(typeSymbol, "ConfigureServices") || ( moduleServicesSymbol is not null - && ImplementsInterface(typeSymbol, moduleServicesSymbol) + && SymbolHelpers.ImplementsInterface( + typeSymbol, + moduleServicesSymbol + ) ), - HasConfigureEndpoints = DeclaresMethod( + HasConfigureEndpoints = SymbolHelpers.DeclaresMethod( typeSymbol, "ConfigureEndpoints" ), HasConfigureMenu = - DeclaresMethod(typeSymbol, "ConfigureMenu") + SymbolHelpers.DeclaresMethod(typeSymbol, "ConfigureMenu") || ( moduleMenuSymbol is not null - && ImplementsInterface(typeSymbol, moduleMenuSymbol) + && SymbolHelpers.ImplementsInterface( + typeSymbol, + moduleMenuSymbol + ) ), HasConfigureMiddleware = - DeclaresMethod(typeSymbol, "ConfigureMiddleware") + SymbolHelpers.DeclaresMethod(typeSymbol, "ConfigureMiddleware") || ( moduleMiddlewareSymbol is not null - && ImplementsInterface(typeSymbol, moduleMiddlewareSymbol) + && SymbolHelpers.ImplementsInterface( + typeSymbol, + moduleMiddlewareSymbol + ) ), - HasConfigurePermissions = DeclaresMethod( + HasConfigurePermissions = SymbolHelpers.DeclaresMethod( typeSymbol, "ConfigurePermissions" ), HasConfigureSettings = - DeclaresMethod(typeSymbol, "ConfigureSettings") + SymbolHelpers.DeclaresMethod(typeSymbol, "ConfigureSettings") || ( moduleSettingsSymbol is not null - && ImplementsInterface(typeSymbol, moduleSettingsSymbol) + && SymbolHelpers.ImplementsInterface( + typeSymbol, + moduleSettingsSymbol + ) ), - HasConfigureFeatureFlags = DeclaresMethod( + HasConfigureFeatureFlags = SymbolHelpers.DeclaresMethod( typeSymbol, "ConfigureFeatureFlags" ), - HasConfigureAgents = DeclaresMethod(typeSymbol, "ConfigureAgents"), - HasConfigureRateLimits = DeclaresMethod( + HasConfigureAgents = SymbolHelpers.DeclaresMethod( + typeSymbol, + "ConfigureAgents" + ), + HasConfigureRateLimits = SymbolHelpers.DeclaresMethod( typeSymbol, "ConfigureRateLimits" ), RoutePrefix = routePrefix, ViewPrefix = viewPrefix, AssemblyName = typeSymbol.ContainingAssembly.Name, - Location = GetSourceLocation(typeSymbol), + Location = SymbolHelpers.GetSourceLocation(typeSymbol), } ); break; @@ -926,7 +918,10 @@ CancellationToken cancellationToken if ( viewEndpointInterfaceSymbol is not null - && ImplementsInterface(typeSymbol, viewEndpointInterfaceSymbol) + && SymbolHelpers.ImplementsInterface( + typeSymbol, + viewEndpointInterfaceSymbol + ) ) { // Infer class name for deferred page name computation @@ -943,14 +938,14 @@ viewEndpointInterfaceSymbol is not null { FullyQualifiedName = fqn, InferredClassName = className, - Location = GetSourceLocation(typeSymbol), + Location = SymbolHelpers.GetSourceLocation(typeSymbol), }; var (viewRoute, _) = ReadRouteConstFields(typeSymbol); viewInfo.RouteTemplate = viewRoute; views.Add(viewInfo); } - else if (ImplementsInterface(typeSymbol, endpointInterfaceSymbol)) + else if (SymbolHelpers.ImplementsInterface(typeSymbol, endpointInterfaceSymbol)) { var info = new EndpointInfo { FullyQualifiedName = fqn }; @@ -1018,58 +1013,6 @@ private static (string route, string method) ReadRouteConstFields(INamedTypeSymb 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, @@ -1142,18 +1085,6 @@ and var baseType } } - 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. @@ -1212,29 +1143,6 @@ private static bool HasDbContextConstructorParam(INamedTypeSymbol typeSymbol) 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, @@ -1314,7 +1222,7 @@ member is INamedTypeSymbol typeSymbol IdentityUserTypeFqn = identityUserFqn, IdentityRoleTypeFqn = identityRoleFqn, IdentityKeyTypeFqn = identityKeyFqn, - Location = GetSourceLocation(typeSymbol), + Location = SymbolHelpers.GetSourceLocation(typeSymbol), }; // Collect DbSet properties @@ -1343,7 +1251,7 @@ m is IPropertySymbol prop PropertyName = prop.Name, EntityFqn = entityFqn, EntityAssemblyName = entityAssemblyName, - EntityLocation = GetSourceLocation(entityType), + EntityLocation = SymbolHelpers.GetSourceLocation(entityType), } ); } @@ -1396,7 +1304,7 @@ member is INamedTypeSymbol typeSymbol ), EntityFqn = entityFqn, ModuleName = moduleName, - Location = GetSourceLocation(typeSymbol), + Location = SymbolHelpers.GetSourceLocation(typeSymbol), } ); break; @@ -1436,7 +1344,7 @@ member is INamedTypeSymbol typeSymbol assemblyName, typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), methodCount, - GetSourceLocation(typeSymbol) + SymbolHelpers.GetSourceLocation(typeSymbol) ) ); } @@ -1481,7 +1389,7 @@ List results IsPublic = typeSymbol.DeclaredAccessibility == Accessibility.Public, IsAbstract = typeSymbol.IsAbstract, DependsOnDbContext = HasDbContextConstructorParam(typeSymbol), - Location = GetSourceLocation(typeSymbol), + Location = SymbolHelpers.GetSourceLocation(typeSymbol), Lifetime = GetContractLifetime(typeSymbol), } ); @@ -1507,7 +1415,7 @@ List results else if ( member is INamedTypeSymbol typeSymbol && typeSymbol.TypeKind == TypeKind.Class - && ImplementsInterface(typeSymbol, modulePermissionsSymbol) + && SymbolHelpers.ImplementsInterface(typeSymbol, modulePermissionsSymbol) ) { var info = new PermissionClassInfo @@ -1517,7 +1425,7 @@ member is INamedTypeSymbol typeSymbol ), ModuleName = moduleName, IsSealed = typeSymbol.IsSealed, - Location = GetSourceLocation(typeSymbol), + Location = SymbolHelpers.GetSourceLocation(typeSymbol), }; // Collect public const string fields @@ -1539,7 +1447,7 @@ m is IFieldSymbol field IsConstString = field.IsConst && field.Type.SpecialType == SpecialType.System_String, - Location = GetSourceLocation(field), + Location = SymbolHelpers.GetSourceLocation(field), } ); } @@ -1566,7 +1474,7 @@ List results else if ( member is INamedTypeSymbol typeSymbol && typeSymbol.TypeKind == TypeKind.Class - && ImplementsInterface(typeSymbol, moduleFeaturesSymbol) + && SymbolHelpers.ImplementsInterface(typeSymbol, moduleFeaturesSymbol) ) { var info = new FeatureClassInfo @@ -1576,7 +1484,7 @@ member is INamedTypeSymbol typeSymbol ), ModuleName = moduleName, IsSealed = typeSymbol.IsSealed, - Location = GetSourceLocation(typeSymbol), + Location = SymbolHelpers.GetSourceLocation(typeSymbol), }; // Collect public const string fields @@ -1598,7 +1506,7 @@ m is IFieldSymbol field IsConstString = field.IsConst && field.Type.SpecialType == SpecialType.System_String, - Location = GetSourceLocation(field), + Location = SymbolHelpers.GetSourceLocation(field), } ); } @@ -1616,7 +1524,7 @@ private static void FindModuleOptionsClasses( List results ) { - FindConcreteClassesImplementing( + SymbolHelpers.FindConcreteClassesImplementing( namespaceSymbol, moduleOptionsSymbol, typeSymbol => @@ -1624,41 +1532,12 @@ List results new ModuleOptionsRecord( typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), moduleName, - GetSourceLocation(typeSymbol) + SymbolHelpers.GetSourceLocation(typeSymbol) ) ) ); } - /// - /// 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, @@ -1677,7 +1556,7 @@ member is INamedTypeSymbol typeSymbol && typeSymbol.TypeKind == TypeKind.Class && !typeSymbol.IsAbstract && !typeSymbol.IsStatic - && ImplementsInterface(typeSymbol, saveChangesInterceptorSymbol) + && SymbolHelpers.ImplementsInterface(typeSymbol, saveChangesInterceptorSymbol) ) { var info = new InterceptorInfo @@ -1686,7 +1565,7 @@ member is INamedTypeSymbol typeSymbol SymbolDisplayFormat.FullyQualifiedFormat ), ModuleName = moduleName, - Location = GetSourceLocation(typeSymbol), + Location = SymbolHelpers.GetSourceLocation(typeSymbol), }; // Extract constructor parameter type FQNs @@ -1996,7 +1875,7 @@ List results member is INamedTypeSymbol typeSymbol && !typeSymbol.IsAbstract && typeSymbol.TypeKind == TypeKind.Class - && ImplementsInterface(typeSymbol, interfaceSymbol) + && SymbolHelpers.ImplementsInterface(typeSymbol, interfaceSymbol) ) { results.Add( diff --git a/framework/SimpleModule.Generator/Discovery/SymbolHelpers.cs b/framework/SimpleModule.Generator/Discovery/SymbolHelpers.cs new file mode 100644 index 00000000..e2b1f945 --- /dev/null +++ b/framework/SimpleModule.Generator/Discovery/SymbolHelpers.cs @@ -0,0 +1,143 @@ +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); + } + } + } +} From 0fbeb8327c4b7c4f95e11be05713896193919062 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 19:58:46 +0200 Subject: [PATCH 10/38] refactor(generator): extract ModuleFinder from SymbolDiscovery --- .../Discovery/Finders/ModuleFinder.cs | 135 +++++++++++++++ .../Discovery/SymbolDiscovery.cs | 155 +----------------- 2 files changed, 139 insertions(+), 151 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/Finders/ModuleFinder.cs 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/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 60cb9647..bb91c215 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -33,25 +33,17 @@ is not IAssemblySymbol assemblySymbol ) continue; - FindModuleTypes( + ModuleFinder.FindModuleTypes( assemblySymbol.GlobalNamespace, - s.ModuleAttribute, - s.ModuleServices, - s.ModuleMenu, - s.ModuleMiddleware, - s.ModuleSettings, + s, modules, cancellationToken ); } - FindModuleTypes( + ModuleFinder.FindModuleTypes( compilation.Assembly.GlobalNamespace, - s.ModuleAttribute, - s.ModuleServices, - s.ModuleMenu, - s.ModuleMiddleware, - s.ModuleSettings, + s, modules, cancellationToken ); @@ -747,145 +739,6 @@ is not IAssemblySymbol assemblySymbol ); } - 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 = - SymbolHelpers.DeclaresMethod(typeSymbol, "ConfigureServices") - || ( - moduleServicesSymbol is not null - && SymbolHelpers.ImplementsInterface( - typeSymbol, - moduleServicesSymbol - ) - ), - HasConfigureEndpoints = SymbolHelpers.DeclaresMethod( - typeSymbol, - "ConfigureEndpoints" - ), - HasConfigureMenu = - SymbolHelpers.DeclaresMethod(typeSymbol, "ConfigureMenu") - || ( - moduleMenuSymbol is not null - && SymbolHelpers.ImplementsInterface( - typeSymbol, - moduleMenuSymbol - ) - ), - HasConfigureMiddleware = - SymbolHelpers.DeclaresMethod(typeSymbol, "ConfigureMiddleware") - || ( - moduleMiddlewareSymbol is not null - && SymbolHelpers.ImplementsInterface( - typeSymbol, - moduleMiddlewareSymbol - ) - ), - HasConfigurePermissions = SymbolHelpers.DeclaresMethod( - typeSymbol, - "ConfigurePermissions" - ), - HasConfigureSettings = - SymbolHelpers.DeclaresMethod(typeSymbol, "ConfigureSettings") - || ( - moduleSettingsSymbol is not null - && SymbolHelpers.ImplementsInterface( - typeSymbol, - moduleSettingsSymbol - ) - ), - 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; - } - } - } - } - } - private static void FindEndpointTypes( INamespaceSymbol namespaceSymbol, INamedTypeSymbol endpointInterfaceSymbol, From e728c7374fc45e3cc2e86f91cc135136e880e7ae Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:02:49 +0200 Subject: [PATCH 11/38] refactor(generator): extract EndpointFinder from SymbolDiscovery --- .../Discovery/Finders/EndpointFinder.cs | 156 ++++++++++ .../Discovery/SymbolDiscovery.cs | 266 +++++------------- 2 files changed, 223 insertions(+), 199 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs diff --git a/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs new file mode 100644 index 00000000..a57b37c3 --- /dev/null +++ b/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs @@ -0,0 +1,156 @@ +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); + } +} diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index bb91c215..ad29dabb 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -64,95 +64,90 @@ is not IAssemblySymbol assemblySymbol // 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 (s.EndpointInterface is not null) + var endpointScannedAssemblies = new HashSet( + SymbolEqualityComparer.Default + ); + foreach (var module in modules) { - var endpointScannedAssemblies = new HashSet( - SymbolEqualityComparer.Default - ); - foreach (var module in modules) - { - cancellationToken.ThrowIfCancellationRequested(); + cancellationToken.ThrowIfCancellationRequested(); - if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol)) - continue; + if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol)) + continue; - var assembly = typeSymbol.ContainingAssembly; - if (!endpointScannedAssemblies.Add(assembly)) - continue; + var assembly = typeSymbol.ContainingAssembly; + if (!endpointScannedAssemblies.Add(assembly)) + continue; - var rawEndpoints = new List(); - var rawViews = new List(); - FindEndpointTypes( - assembly.GlobalNamespace, - s.EndpointInterface, - s.ViewEndpointInterface, - rawEndpoints, - rawViews, - cancellationToken - ); + var rawEndpoints = new List(); + var rawViews = new List(); + EndpointFinder.FindEndpointTypes( + assembly.GlobalNamespace, + s, + 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.FindClosestModuleName(epFqn, modules); - var owner = modules.Find(m => m.ModuleName == ownerName); - if (owner is not null) - owner.Endpoints.Add(ep); - } + // 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.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) + // Pre-compute module namespace per module name for page inference + var moduleNsByName = new Dictionary(); + foreach (var m in modules) + { + if (!moduleNsByName.ContainsKey(m.ModuleName)) { - if (!moduleNsByName.ContainsKey(m.ModuleName)) - { - var mFqn = TypeMappingHelpers.StripGlobalPrefix(m.FullyQualifiedName); - moduleNsByName[m.ModuleName] = TypeMappingHelpers.ExtractNamespace(mFqn); - } + var mFqn = TypeMappingHelpers.StripGlobalPrefix(m.FullyQualifiedName); + moduleNsByName[m.ModuleName] = TypeMappingHelpers.ExtractNamespace(mFqn); } + } - foreach (var v in rawViews) + foreach (var v in rawViews) + { + var vFqn = TypeMappingHelpers.StripGlobalPrefix(v.FullyQualifiedName); + var ownerName = SymbolHelpers.FindClosestModuleName(vFqn, modules); + var owner = modules.Find(m => m.ModuleName == ownerName); + if (owner is not null) { - var vFqn = TypeMappingHelpers.StripGlobalPrefix(v.FullyQualifiedName); - var ownerName = SymbolHelpers.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) { - // 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) { - 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) + ) { - if ( - seg.Length > 0 - && !seg.Equals("Views", StringComparison.Ordinal) - && !seg.Equals("Pages", StringComparison.Ordinal) - ) - { - pathParts.Add(seg); - } + pathParts.Add(seg); } - - var subPath = - pathParts.Count > 0 ? string.Join("/", pathParts) + "/" : ""; - v.Page = ownerName + "/" + subPath + v.InferredClassName; } - owner.Views.Add(v); + var subPath = pathParts.Count > 0 ? string.Join("/", pathParts) + "/" : ""; + v.Page = ownerName + "/" + subPath + v.InferredClassName; } + + owner.Views.Add(v); } } } @@ -739,133 +734,6 @@ is not IAssemblySymbol assemblySymbol ); } - 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 - && SymbolHelpers.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 = 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); - } - private static void FindDtoTypes( INamespaceSymbol namespaceSymbol, INamedTypeSymbol dtoAttributeSymbol, From a25ed896726760ee6e8e742a5493aae3f548b177 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:06:17 +0200 Subject: [PATCH 12/38] refactor(generator): extract DtoFinder from SymbolDiscovery --- .../Discovery/Finders/DtoFinder.cs | 271 ++++++++++++++++++ .../Discovery/SymbolDiscovery.cs | 271 +----------------- 2 files changed, 276 insertions(+), 266 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs diff --git a/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs new file mode 100644 index 00000000..e9d7cc0f --- /dev/null +++ b/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs @@ -0,0 +1,271 @@ +using System; +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 = ExtractDtoProperties(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) + { + 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 (SymbolDiscovery.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 = SymbolDiscovery.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/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index ad29dabb..fb4259a1 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -208,7 +208,7 @@ is not IAssemblySymbol assemblySymbol ) continue; - FindDtoTypes( + DtoFinder.FindDtoTypes( assemblySymbol.GlobalNamespace, s.DtoAttribute, dtoTypes, @@ -216,7 +216,7 @@ is not IAssemblySymbol assemblySymbol ); } - FindDtoTypes( + DtoFinder.FindDtoTypes( compilation.Assembly.GlobalNamespace, s.DtoAttribute, dtoTypes, @@ -287,7 +287,7 @@ is not IAssemblySymbol assemblySymbol foreach (var kvp in contractsAssemblySymbols) { cancellationToken.ThrowIfCancellationRequested(); - FindConventionDtoTypes( + DtoFinder.FindConventionDtoTypes( kvp.Value.GlobalNamespace, s.NoDtoAttribute, s.EventInterface, @@ -734,78 +734,6 @@ is not IAssemblySymbol assemblySymbol ); } - 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; - } - } - } - } - } - /// /// Reads [ContractLifetime(ServiceLifetime.X)] from the type. /// Returns 1 (Scoped) if the attribute is not present. @@ -1311,195 +1239,6 @@ member is INamedTypeSymbol typeSymbol } } - 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 @@ -1531,7 +1270,7 @@ List results } } - private static bool IsVogenValueObject(INamedTypeSymbol typeSymbol) + internal static bool IsVogenValueObject(INamedTypeSymbol typeSymbol) { foreach (var attr in typeSymbol.GetAttributes()) { @@ -1554,7 +1293,7 @@ attrClass is not null /// 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) + internal static string ResolveUnderlyingType(ITypeSymbol typeSymbol) { foreach (var attr in typeSymbol.GetAttributes()) { From 49ea565686c3788991152b3148d955b0a89e9e9a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:08:50 +0200 Subject: [PATCH 13/38] refactor(generator): extract DbContextFinder from SymbolDiscovery --- .../Discovery/Finders/DbContextFinder.cs | 213 +++++++++++++++++ .../Discovery/SymbolDiscovery.cs | 218 +----------------- 2 files changed, 223 insertions(+), 208 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs diff --git a/framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs new file mode 100644 index 00000000..a796f75a --- /dev/null +++ b/framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs @@ -0,0 +1,213 @@ +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; + } + } + } + } + } + + 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/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index fb4259a1..c391bae1 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -171,8 +171,13 @@ is not IAssemblySymbol assemblySymbol // Collect unmatched items from this assembly var rawDbContexts = new List(); var rawEntityConfigs = new List(); - FindDbContextTypes(assembly.GlobalNamespace, "", rawDbContexts, cancellationToken); - FindEntityConfigTypes( + DbContextFinder.FindDbContextTypes( + assembly.GlobalNamespace, + "", + rawDbContexts, + cancellationToken + ); + DbContextFinder.FindEntityConfigTypes( assembly.GlobalNamespace, "", rawEntityConfigs, @@ -758,211 +763,6 @@ private static int GetContractLifetime(INamedTypeSymbol typeSymbol) 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 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); - } - } - } - - 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 = SymbolHelpers.GetSourceLocation(typeSymbol), - } - ); - break; - } - } - } - } - } - private static void ScanContractInterfaces( INamespaceSymbol namespaceSymbol, string assemblyName, @@ -1037,7 +837,9 @@ List results ModuleName = moduleName, IsPublic = typeSymbol.DeclaredAccessibility == Accessibility.Public, IsAbstract = typeSymbol.IsAbstract, - DependsOnDbContext = HasDbContextConstructorParam(typeSymbol), + DependsOnDbContext = DbContextFinder.HasDbContextConstructorParam( + typeSymbol + ), Location = SymbolHelpers.GetSourceLocation(typeSymbol), Lifetime = GetContractLifetime(typeSymbol), } From 8dc890f0c59a5818dd23fa6ea8bec265c5cea8e5 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:10:57 +0200 Subject: [PATCH 14/38] refactor(generator): extract ContractFinder from SymbolDiscovery --- .../Discovery/Finders/ContractFinder.cs | 118 +++++++++++++++++ .../Discovery/SymbolDiscovery.cs | 119 +----------------- 2 files changed, 124 insertions(+), 113 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs diff --git a/framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs new file mode 100644 index 00000000..880d453a --- /dev/null +++ b/framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs @@ -0,0 +1,118 @@ +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 + } +} diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index c391bae1..650de4f8 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -306,7 +306,11 @@ is not IAssemblySymbol assemblySymbol var contractInterfaces = new List(); foreach (var kvp in contractsAssemblySymbols) { - ScanContractInterfaces(kvp.Value.GlobalNamespace, kvp.Key, contractInterfaces); + ContractFinder.ScanContractInterfaces( + kvp.Value.GlobalNamespace, + kvp.Key, + contractInterfaces + ); } // Step 3b: Find implementations of contract interfaces in module assemblies @@ -334,7 +338,7 @@ is not IAssemblySymbol assemblySymbol continue; // Scan module assembly for implementations - FindContractImplementations( + ContractFinder.FindContractImplementations( moduleAssembly.GlobalNamespace, moduleContractInterfaceFqns, module.ModuleName, @@ -739,117 +743,6 @@ is not IAssemblySymbol assemblySymbol ); } - /// - /// 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 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) - ) - ); - } - } - } - - 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 = DbContextFinder.HasDbContextConstructorParam( - typeSymbol - ), - Location = SymbolHelpers.GetSourceLocation(typeSymbol), - Lifetime = GetContractLifetime(typeSymbol), - } - ); - } - } - } - } - } - private static void FindPermissionClasses( INamespaceSymbol namespaceSymbol, INamedTypeSymbol modulePermissionsSymbol, From cc8f0b2bf7561dd0023b02501fae29d7c480789b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:13:09 +0200 Subject: [PATCH 15/38] refactor(generator): extract PermissionFeatureFinder from SymbolDiscovery --- .../Finders/PermissionFeatureFinder.cs | 146 +++++++++++++++++ .../Discovery/SymbolDiscovery.cs | 151 +----------------- 2 files changed, 152 insertions(+), 145 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/Finders/PermissionFeatureFinder.cs diff --git a/framework/SimpleModule.Generator/Discovery/Finders/PermissionFeatureFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/PermissionFeatureFinder.cs new file mode 100644 index 00000000..db59f7b2 --- /dev/null +++ b/framework/SimpleModule.Generator/Discovery/Finders/PermissionFeatureFinder.cs @@ -0,0 +1,146 @@ +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) + ) + ) + ); + } +} diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 650de4f8..3998b39d 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -357,7 +357,7 @@ is not IAssemblySymbol assemblySymbol continue; var moduleAssembly = typeSymbol.ContainingAssembly; - FindPermissionClasses( + PermissionFeatureFinder.FindPermissionClasses( moduleAssembly.GlobalNamespace, s.ModulePermissions, module.ModuleName, @@ -370,7 +370,7 @@ is not IAssemblySymbol assemblySymbol { if (contractsAssemblyMap.TryGetValue(kvp.Key, out var moduleName)) { - FindPermissionClasses( + PermissionFeatureFinder.FindPermissionClasses( kvp.Value.GlobalNamespace, s.ModulePermissions, moduleName, @@ -390,7 +390,7 @@ is not IAssemblySymbol assemblySymbol continue; var moduleAssembly = typeSymbol.ContainingAssembly; - FindFeatureClasses( + PermissionFeatureFinder.FindFeatureClasses( moduleAssembly.GlobalNamespace, s.ModuleFeatures, module.ModuleName, @@ -403,7 +403,7 @@ is not IAssemblySymbol assemblySymbol { if (contractsAssemblyMap.TryGetValue(kvp.Key, out var moduleName)) { - FindFeatureClasses( + PermissionFeatureFinder.FindFeatureClasses( kvp.Value.GlobalNamespace, s.ModuleFeatures, moduleName, @@ -468,7 +468,7 @@ is not IAssemblySymbol assemblySymbol modules, moduleSymbols, (assembly, module) => - FindModuleOptionsClasses( + PermissionFeatureFinder.FindModuleOptionsClasses( assembly.GlobalNamespace, s.ModuleOptions, module.ModuleName, @@ -481,7 +481,7 @@ is not IAssemblySymbol assemblySymbol { if (contractsAssemblyMap.TryGetValue(kvp.Key, out var moduleName)) { - FindModuleOptionsClasses( + PermissionFeatureFinder.FindModuleOptionsClasses( kvp.Value.GlobalNamespace, s.ModuleOptions, moduleName, @@ -743,145 +743,6 @@ is not IAssemblySymbol assemblySymbol ); } - 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 - && 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); - } - } - } - - 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 - && 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); - } - } - } - - private 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) - ) - ) - ); - } - private static void FindInterceptorTypes( INamespaceSymbol namespaceSymbol, INamedTypeSymbol saveChangesInterceptorSymbol, From 8365aa89cd740425b1235e7469ffc141781fb6f5 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:15:57 +0200 Subject: [PATCH 16/38] refactor(generator): extract Vogen, Interceptor, Agent finders --- .../Discovery/Finders/AgentFinder.cs | 40 ++++ .../Discovery/Finders/DtoFinder.cs | 4 +- .../Discovery/Finders/InterceptorFinder.cs | 59 ++++++ .../Discovery/Finders/VogenFinder.cs | 86 +++++++++ .../Discovery/SymbolDiscovery.cs | 179 +----------------- 5 files changed, 196 insertions(+), 172 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/Finders/AgentFinder.cs create mode 100644 framework/SimpleModule.Generator/Discovery/Finders/InterceptorFinder.cs create mode 100644 framework/SimpleModule.Generator/Discovery/Finders/VogenFinder.cs diff --git a/framework/SimpleModule.Generator/Discovery/Finders/AgentFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/AgentFinder.cs new file mode 100644 index 00000000..72e0ae27 --- /dev/null +++ b/framework/SimpleModule.Generator/Discovery/Finders/AgentFinder.cs @@ -0,0 +1,40 @@ +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, + } + ); + } + } + } +} diff --git a/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs index e9d7cc0f..4c3c88d0 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs @@ -169,7 +169,7 @@ member is INamedTypeSymbol typeSymbol // Skip Vogen value objects — they have their own JsonConverter // and must not be treated as regular DTOs in the JSON resolver - if (SymbolDiscovery.IsVogenValueObject(typeSymbol)) + if (VogenFinder.IsVogenValueObject(typeSymbol)) continue; var safeName = TypeMappingHelpers.StripGlobalPrefix(fqn).Replace(".", "_"); @@ -230,7 +230,7 @@ m is IPropertySymbol prop && seen.Add(prop.Name) ) { - var resolvedType = SymbolDiscovery.ResolveUnderlyingType(prop.Type); + var resolvedType = VogenFinder.ResolveUnderlyingType(prop.Type); var actualType = prop.Type.ToDisplayString( SymbolDisplayFormat.FullyQualifiedFormat ); diff --git a/framework/SimpleModule.Generator/Discovery/Finders/InterceptorFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/InterceptorFinder.cs new file mode 100644 index 00000000..1b71cebf --- /dev/null +++ b/framework/SimpleModule.Generator/Discovery/Finders/InterceptorFinder.cs @@ -0,0 +1,59 @@ +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); + } + } + } +} diff --git a/framework/SimpleModule.Generator/Discovery/Finders/VogenFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/VogenFinder.cs new file mode 100644 index 00000000..80b98484 --- /dev/null +++ b/framework/SimpleModule.Generator/Discovery/Finders/VogenFinder.cs @@ -0,0 +1,86 @@ +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; + } + + /// + /// 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/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 3998b39d..543d0adc 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -422,7 +422,7 @@ is not IAssemblySymbol assemblySymbol moduleSymbols, (assembly, module) => { - FindInterceptorTypes( + InterceptorFinder.FindInterceptorTypes( assembly.GlobalNamespace, s.SaveChangesInterceptor, module.ModuleName, @@ -441,7 +441,10 @@ is not IAssemblySymbol assemblySymbol { if (voScannedAssemblies.Add(kvp.Value)) { - FindVogenValueObjectsWithEfConverters(kvp.Value.GlobalNamespace, vogenValueObjects); + VogenFinder.FindVogenValueObjectsWithEfConverters( + kvp.Value.GlobalNamespace, + vogenValueObjects + ); } } @@ -452,7 +455,7 @@ is not IAssemblySymbol assemblySymbol { if (voScannedAssemblies.Add(assembly)) { - FindVogenValueObjectsWithEfConverters( + VogenFinder.FindVogenValueObjectsWithEfConverters( assembly.GlobalNamespace, vogenValueObjects ); @@ -502,7 +505,7 @@ is not IAssemblySymbol assemblySymbol modules, moduleSymbols, (assembly, module) => - FindImplementors( + AgentFinder.FindImplementors( assembly.GlobalNamespace, s.AgentDefinition, module.ModuleName, @@ -517,7 +520,7 @@ is not IAssemblySymbol assemblySymbol modules, moduleSymbols, (assembly, module) => - FindImplementors( + AgentFinder.FindImplementors( assembly.GlobalNamespace, s.AgentToolProvider, module.ModuleName, @@ -532,7 +535,7 @@ is not IAssemblySymbol assemblySymbol modules, moduleSymbols, (assembly, module) => - FindImplementors( + AgentFinder.FindImplementors( assembly.GlobalNamespace, s.KnowledgeSource, module.ModuleName, @@ -742,168 +745,4 @@ is not IAssemblySymbol assemblySymbol hostAssemblyName ); } - - 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 - && 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); - } - } - } - - 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); - } - } - - 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; - } - - /// - /// 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); - } - - 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 - && SymbolHelpers.ImplementsInterface(typeSymbol, interfaceSymbol) - ) - { - results.Add( - new DiscoveredTypeInfo - { - FullyQualifiedName = typeSymbol.ToDisplayString( - SymbolDisplayFormat.FullyQualifiedFormat - ), - ModuleName = moduleName, - } - ); - } - } - } } From a717d4808129abdc78380b79eaf99b2e299f0235 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:23:38 +0200 Subject: [PATCH 17/38] refactor(generator): pull contract orchestration into ContractFinder.Discover* Move the Step 2 (contracts-assembly map), Step 3 (interface scan), and Step 3b (implementation scan) blocks from SymbolDiscovery.Extract into two new orchestration methods: ContractFinder.BuildContractsAssemblyMap and ContractFinder.DiscoverInterfacesAndImplementations. The convention-DTO scan block between them remains in Extract. --- .../Discovery/Finders/ContractFinder.cs | 104 ++++++++++++++++++ .../Discovery/SymbolDiscovery.cs | 91 +++------------ 2 files changed, 119 insertions(+), 76 deletions(-) diff --git a/framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs index 880d453a..16635b62 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Microsoft.CodeAnalysis; @@ -115,4 +116,107 @@ internal static int GetContractLifetime(INamedTypeSymbol typeSymbol) } return 1; // Default: Scoped } + + /// + /// Finds every *.Contracts assembly referenced by the compilation and maps it + /// to the module it belongs to. Populates + /// (name → module name) and + /// (name → IAssemblySymbol) for downstream scans. + /// + internal static void BuildContractsAssemblyMap( + Compilation compilation, + Dictionary moduleAssemblyMap, + Dictionary contractsAssemblyMap, + Dictionary contractsAssemblySymbols + ) + { + 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; + } + } + } + } + + /// + /// 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/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 543d0adc..c5afc1b4 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -249,40 +249,12 @@ 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( + compilation, + moduleAssemblyMap, + contractsAssemblyMap, + contractsAssemblySymbols + ); // Convention-based DTO discovery: all public types in *.Contracts assemblies var existingDtoFqns = new HashSet(); @@ -302,50 +274,17 @@ is not IAssemblySymbol assemblySymbol ); } - // Step 3: Scan contract interfaces + // Step 3/3b: Contract interfaces and implementations var contractInterfaces = new List(); - foreach (var kvp in contractsAssemblySymbols) - { - ContractFinder.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 - ContractFinder.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(); From 9243c96b48e5af9f636d555ba95409a75cb9051c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:26:00 +0200 Subject: [PATCH 18/38] refactor(generator): pull permission/feature/options orchestration into PermissionFeatureFinder.Discover* --- .../Finders/PermissionFeatureFinder.cs | 136 ++++++++++++++++++ .../Discovery/SymbolDiscovery.cs | 112 ++++----------- 2 files changed, 160 insertions(+), 88 deletions(-) diff --git a/framework/SimpleModule.Generator/Discovery/Finders/PermissionFeatureFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/PermissionFeatureFinder.cs index db59f7b2..a3d574a7 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/PermissionFeatureFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/PermissionFeatureFinder.cs @@ -143,4 +143,140 @@ List results ) ); } + + /// + /// 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/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index c5afc1b4..acc5cdd5 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -288,69 +288,25 @@ is not IAssemblySymbol assemblySymbol // Step 3c: Find IModulePermissions implementors in module and contracts assemblies var permissionClasses = new List(); - if (s.ModulePermissions is not null) - { - foreach (var module in modules) - { - if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol)) - continue; - - var moduleAssembly = typeSymbol.ContainingAssembly; - PermissionFeatureFinder.FindPermissionClasses( - moduleAssembly.GlobalNamespace, - s.ModulePermissions, - module.ModuleName, - permissionClasses - ); - } - - // Also scan contracts assemblies for permission classes - foreach (var kvp in contractsAssemblySymbols) - { - if (contractsAssemblyMap.TryGetValue(kvp.Key, out var moduleName)) - { - PermissionFeatureFinder.FindPermissionClasses( - kvp.Value.GlobalNamespace, - s.ModulePermissions, - moduleName, - permissionClasses - ); - } - } - } + PermissionFeatureFinder.DiscoverPermissions( + modules, + moduleSymbols, + contractsAssemblySymbols, + contractsAssemblyMap, + s, + permissionClasses + ); // Step 3d: Find IModuleFeatures implementors in module and contracts assemblies var featureClasses = new List(); - if (s.ModuleFeatures is not null) - { - foreach (var module in modules) - { - if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var typeSymbol)) - continue; - - var moduleAssembly = typeSymbol.ContainingAssembly; - PermissionFeatureFinder.FindFeatureClasses( - moduleAssembly.GlobalNamespace, - s.ModuleFeatures, - module.ModuleName, - featureClasses - ); - } - - // Also scan contracts assemblies for feature classes - foreach (var kvp in contractsAssemblySymbols) - { - if (contractsAssemblyMap.TryGetValue(kvp.Key, out var moduleName)) - { - PermissionFeatureFinder.FindFeatureClasses( - kvp.Value.GlobalNamespace, - s.ModuleFeatures, - moduleName, - featureClasses - ); - } - } - } + PermissionFeatureFinder.DiscoverFeatures( + modules, + moduleSymbols, + contractsAssemblySymbols, + contractsAssemblyMap, + s, + featureClasses + ); // Step 3e: Find ISaveChangesInterceptor implementors in module assemblies var interceptors = new List(); @@ -404,34 +360,14 @@ is not IAssemblySymbol assemblySymbol // Step 3f: Find IModuleOptions implementors in module and contracts assemblies var moduleOptionsList = new List(); - if (s.ModuleOptions is not null) - { - SymbolHelpers.ScanModuleAssemblies( - modules, - moduleSymbols, - (assembly, module) => - PermissionFeatureFinder.FindModuleOptionsClasses( - assembly.GlobalNamespace, - s.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)) - { - PermissionFeatureFinder.FindModuleOptionsClasses( - kvp.Value.GlobalNamespace, - s.ModuleOptions, - moduleName, - moduleOptionsList - ); - } - } - } + PermissionFeatureFinder.DiscoverModuleOptions( + modules, + moduleSymbols, + contractsAssemblySymbols, + contractsAssemblyMap, + s, + moduleOptionsList + ); // Step 3g: Find IAgentDefinition, IAgentToolProvider, and IKnowledgeSource implementors var agentDefinitions = new List(); From 906c78933a2913d790664ffa53e49b8b985ff882 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:28:37 +0200 Subject: [PATCH 19/38] refactor(generator): pull interceptor/vogen/agent orchestration into finder classes --- .../Discovery/Finders/AgentFinder.cs | 61 ++++++++++ .../Discovery/Finders/InterceptorFinder.cs | 29 +++++ .../Discovery/Finders/VogenFinder.cs | 38 +++++++ .../Discovery/SymbolDiscovery.cs | 105 +++--------------- 4 files changed, 141 insertions(+), 92 deletions(-) diff --git a/framework/SimpleModule.Generator/Discovery/Finders/AgentFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/AgentFinder.cs index 72e0ae27..c752c765 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/AgentFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/AgentFinder.cs @@ -37,4 +37,65 @@ member is INamedTypeSymbol typeSymbol } } } + + /// + /// 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/InterceptorFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/InterceptorFinder.cs index 1b71cebf..4c5230ca 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/InterceptorFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/InterceptorFinder.cs @@ -56,4 +56,33 @@ member is INamedTypeSymbol typeSymbol } } } + + /// + /// 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/VogenFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/VogenFinder.cs index 80b98484..1b30d17b 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/VogenFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/VogenFinder.cs @@ -55,6 +55,44 @@ attrClass is not null 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. diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index acc5cdd5..86174784 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -308,55 +308,13 @@ is not IAssemblySymbol assemblySymbol featureClasses ); - // Step 3e: Find ISaveChangesInterceptor implementors in module assemblies + // Step 3e: ISaveChangesInterceptor implementors var interceptors = new List(); - if (s.SaveChangesInterceptor is not null) - { - SymbolHelpers.ScanModuleAssemblies( - modules, - moduleSymbols, - (assembly, module) => - { - InterceptorFinder.FindInterceptorTypes( - assembly.GlobalNamespace, - s.SaveChangesInterceptor, - 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); - - foreach (var kvp in contractsAssemblySymbols) - { - if (voScannedAssemblies.Add(kvp.Value)) - { - VogenFinder.FindVogenValueObjectsWithEfConverters( - kvp.Value.GlobalNamespace, - vogenValueObjects - ); - } - } - - SymbolHelpers.ScanModuleAssemblies( - modules, - moduleSymbols, - (assembly, _) => - { - if (voScannedAssemblies.Add(assembly)) - { - VogenFinder.FindVogenValueObjectsWithEfConverters( - assembly.GlobalNamespace, - vogenValueObjects - ); - } - } - ); + VogenFinder.Discover(modules, moduleSymbols, contractsAssemblySymbols, vogenValueObjects); // Step 3f: Find IModuleOptions implementors in module and contracts assemblies var moduleOptionsList = new List(); @@ -369,55 +327,18 @@ is not IAssemblySymbol assemblySymbol 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 (s.AgentDefinition is not null) - { - SymbolHelpers.ScanModuleAssemblies( - modules, - moduleSymbols, - (assembly, module) => - AgentFinder.FindImplementors( - assembly.GlobalNamespace, - s.AgentDefinition, - module.ModuleName, - agentDefinitions - ) - ); - } - - if (s.AgentToolProvider is not null) - { - SymbolHelpers.ScanModuleAssemblies( - modules, - moduleSymbols, - (assembly, module) => - AgentFinder.FindImplementors( - assembly.GlobalNamespace, - s.AgentToolProvider, - module.ModuleName, - agentToolProviders - ) - ); - } - - if (s.KnowledgeSource is not null) - { - SymbolHelpers.ScanModuleAssemblies( - modules, - moduleSymbols, - (assembly, module) => - AgentFinder.FindImplementors( - assembly.GlobalNamespace, - s.KnowledgeSource, - module.ModuleName, - knowledgeSources - ) - ); - } + AgentFinder.DiscoverAll( + modules, + moduleSymbols, + s, + agentDefinitions, + agentToolProviders, + knowledgeSources + ); // Step 4: Detect dependencies and illegal references var dependencies = new List(); From dacb046d0338f86ecf7244cdcac5110dad8f95cc Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:30:38 +0200 Subject: [PATCH 20/38] refactor(generator): extract DependencyAnalyzer (Step 4) from SymbolDiscovery --- .../Discovery/DependencyAnalyzer.cs | 80 +++++++++++++++++++ .../Discovery/SymbolDiscovery.cs | 61 ++------------ 2 files changed, 88 insertions(+), 53 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/DependencyAnalyzer.cs 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/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 86174784..db12a928 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -343,59 +343,14 @@ is not IAssemblySymbol assemblySymbol // 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) - ); - } - } - } - } + DependencyAnalyzer.Analyze( + modules, + moduleSymbols, + moduleAssemblyMap, + contractsAssemblyMap, + dependencies, + illegalReferences + ); return new DiscoveryData( modules From b7a1b08914de07b040177457d54a49cec75507c7 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:34:44 +0200 Subject: [PATCH 21/38] refactor(generator): pull endpoint/dbcontext/dto scan orchestration into finders --- .../Discovery/Finders/DbContextFinder.cs | 53 ++++++ .../Discovery/Finders/DtoFinder.cs | 40 ++++ .../Discovery/Finders/EndpointFinder.cs | 103 +++++++++++ .../Discovery/SymbolDiscovery.cs | 173 ++---------------- 4 files changed, 207 insertions(+), 162 deletions(-) diff --git a/framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs index a796f75a..61af9957 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs @@ -177,6 +177,59 @@ member is INamedTypeSymbol typeSymbol } } + /// + /// 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, + 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.FindClosestModuleName(ctxNs, modules); + dbContexts.Add(ctx); + } + + foreach (var cfg in rawEntityConfigs) + { + var cfgNs = TypeMappingHelpers.StripGlobalPrefix(cfg.ConfigFqn); + cfg.ModuleName = SymbolHelpers.FindClosestModuleName(cfgNs, modules); + entityConfigs.Add(cfg); + } + } + } + internal static bool HasDbContextConstructorParam(INamedTypeSymbol typeSymbol) { foreach (var ctor in typeSymbol.Constructors) diff --git a/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs index 4c3c88d0..08a2de88 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs @@ -251,6 +251,46 @@ prop.SetMethod is not null return properties; } + /// + /// 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( + Compilation compilation, + CoreSymbols symbols, + List dtoTypes, + CancellationToken cancellationToken + ) + { + if (symbols.DtoAttribute is null) + return; + + foreach (var reference in compilation.References) + { + cancellationToken.ThrowIfCancellationRequested(); + + if ( + compilation.GetAssemblyOrModuleSymbol(reference) + is not IAssemblySymbol assemblySymbol + ) + continue; + + FindDtoTypes( + assemblySymbol.GlobalNamespace, + symbols.DtoAttribute, + dtoTypes, + cancellationToken + ); + } + + FindDtoTypes( + compilation.Assembly.GlobalNamespace, + symbols.DtoAttribute, + dtoTypes, + cancellationToken + ); + } + /// /// Returns true if the property is decorated with [System.Text.Json.Serialization.JsonIgnore]. /// Properties marked this way are excluded from generated JSON metadata, mirroring diff --git a/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs index a57b37c3..b5f23106 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs @@ -153,4 +153,107 @@ private static (string route, string method) ReadRouteConstFields(INamedTypeSymb } 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, + CoreSymbols symbols, + CancellationToken cancellationToken + ) + { + 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, + 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.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 = SymbolHelpers.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); + } + } + + 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/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index db12a928..6cbd7402 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -61,173 +61,22 @@ 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. - 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(); - EndpointFinder.FindEndpointTypes( - assembly.GlobalNamespace, - s, - rawEndpoints, - rawViews, - cancellationToken - ); + // Discover IEndpoint and IViewEndpoint implementors per module assembly + EndpointFinder.Discover(modules, moduleSymbols, s, 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.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 = SymbolHelpers.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); - } - } - - var subPath = pathParts.Count > 0 ? string.Join("/", pathParts) + "/" : ""; - v.Page = ownerName + "/" + subPath + v.InferredClassName; - } - - owner.Views.Add(v); - } - } - } - - // 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(); - DbContextFinder.FindDbContextTypes( - assembly.GlobalNamespace, - "", - rawDbContexts, - cancellationToken - ); - DbContextFinder.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.FindClosestModuleName(ctxNs, modules); - dbContexts.Add(ctx); - } - - foreach (var cfg in rawEntityConfigs) - { - var cfgNs = TypeMappingHelpers.StripGlobalPrefix(cfg.ConfigFqn); - cfg.ModuleName = SymbolHelpers.FindClosestModuleName(cfgNs, modules); - entityConfigs.Add(cfg); - } - } + DbContextFinder.Discover( + modules, + moduleSymbols, + dbContexts, + entityConfigs, + cancellationToken + ); var dtoTypes = new List(); - if (s.DtoAttribute is not null) - { - foreach (var reference in compilation.References) - { - cancellationToken.ThrowIfCancellationRequested(); - - if ( - compilation.GetAssemblyOrModuleSymbol(reference) - is not IAssemblySymbol assemblySymbol - ) - continue; - - DtoFinder.FindDtoTypes( - assemblySymbol.GlobalNamespace, - s.DtoAttribute, - dtoTypes, - cancellationToken - ); - } - - DtoFinder.FindDtoTypes( - compilation.Assembly.GlobalNamespace, - s.DtoAttribute, - dtoTypes, - cancellationToken - ); - } + DtoFinder.DiscoverAttributedDtos(compilation, s, dtoTypes, cancellationToken); // --- Dependency inference --- cancellationToken.ThrowIfCancellationRequested(); From c7a690b3d53384f75ec01feae389ce4b529df361 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:37:19 +0200 Subject: [PATCH 22/38] refactor(generator): extract DiscoveryDataBuilder for final conversion block --- .../Discovery/DiscoveryDataBuilder.cs | 181 ++++++++++++++++++ .../Discovery/SymbolDiscovery.cs | 160 ++-------------- 2 files changed, 199 insertions(+), 142 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/DiscoveryDataBuilder.cs 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/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 6cbd7402..e57726d2 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; @@ -201,146 +199,24 @@ is not IAssemblySymbol assemblySymbol illegalReferences ); - 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(), + return DiscoveryDataBuilder.Build( + modules, + dtoTypes, + dbContexts, + entityConfigs, + dependencies, + illegalReferences, + contractInterfaces, + contractImplementations, + permissionClasses, + featureClasses, + interceptors, + vogenValueObjects, + moduleOptionsList, + agentDefinitions, + agentToolProviders, + knowledgeSources, + contractsAssemblyMap, s.HasAgentsAssembly, hostAssemblyName ); From e71d5846087bc289ac534c7472fd8b56c4c21a10 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:40:48 +0200 Subject: [PATCH 23/38] refactor(generator): split DataRecords and DtoFinder below 300-line cap Move mutable working types (ModuleInfo, EndpointInfo, ViewInfo, etc.) from DataRecords.cs into WorkingTypes.cs, and extract ExtractDtoProperties + HasJsonIgnoreAttribute from DtoFinder.cs into DtoPropertyExtractor.cs. --- .../Discovery/Finders/DtoFinder.cs | 70 +------- .../Discovery/Finders/DtoPropertyExtractor.cs | 75 +++++++++ .../Discovery/Records/DataRecords.cs | 153 ----------------- .../Discovery/Records/WorkingTypes.cs | 158 ++++++++++++++++++ 4 files changed, 235 insertions(+), 221 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/Finders/DtoPropertyExtractor.cs create mode 100644 framework/SimpleModule.Generator/Discovery/Records/WorkingTypes.cs diff --git a/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs index 08a2de88..b276eee7 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -70,7 +69,7 @@ and var baseType FullyQualifiedName = fqn, SafeName = safeName, BaseTypeFqn = baseTypeFqn, - Properties = ExtractDtoProperties(typeSymbol), + Properties = DtoPropertyExtractor.Extract(typeSymbol), } ); break; @@ -197,60 +196,13 @@ and var baseType FullyQualifiedName = fqn, SafeName = safeName, BaseTypeFqn = baseTypeFqn, - Properties = ExtractDtoProperties(typeSymbol), + Properties = DtoPropertyExtractor.Extract(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 = 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; - } - /// /// Scans every referenced assembly AND the host assembly for types decorated /// with [Dto]. No-op when the DtoAttribute symbol isn't resolvable. @@ -290,22 +242,4 @@ is not IAssemblySymbol assemblySymbol cancellationToken ); } - - /// - /// 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/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/Records/DataRecords.cs b/framework/SimpleModule.Generator/Discovery/Records/DataRecords.cs index d4373c4c..4abfb5bf 100644 --- a/framework/SimpleModule.Generator/Discovery/Records/DataRecords.cs +++ b/framework/SimpleModule.Generator/Discovery/Records/DataRecords.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -237,155 +236,3 @@ string ModuleName ); internal readonly record struct KnowledgeSourceRecord(string FullyQualifiedName, string ModuleName); - -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/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; } = ""; +} From 250439c9753e740f35e78e1995ba8d5e49d69c0d Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:48:01 +0200 Subject: [PATCH 24/38] refactor(generator): extract 38 DiagnosticDescriptors into their own file Move all DiagnosticDescriptor static fields from DiagnosticEmitter into a dedicated DiagnosticDescriptors static class. Fixes the one external reference in HostDbContextEmitter to use the new class as well. --- .../Emitters/DiagnosticEmitter.cs | 423 ++---------------- .../Diagnostics/DiagnosticDescriptors.cs | 348 ++++++++++++++ .../Emitters/HostDbContextEmitter.cs | 2 +- 3 files changed, 392 insertions(+), 381 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.cs diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index 0e3c556c..bebc7917 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -9,348 +9,6 @@ namespace SimpleModule.Generator; 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 to handle those events. Learn more: https://docs.simplemodule.dev/module-dependencies.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor IllegalImplementationReference = new( - id: "SM0011", - title: "Module directly references another module's implementation", - messageFormat: "Module '{0}' directly references module '{1}' implementation assembly '{2}'. Modules must only depend on each other through Contracts packages. This creates tight coupling \u2014 internal changes in {1} can break {0} at compile time or runtime. To fix: (1) Remove the reference to '{2}'. (2) Add a reference to '{1}.Contracts' instead. (3) Replace any usage of internal {1} types with their contract interfaces. Learn more: https://docs.simplemodule.dev/module-contracts.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor ContractInterfaceTooLargeWarning = new( - id: "SM0012", - title: "Contract interface has too many methods", - messageFormat: "Contract interface '{0}' has {1} methods, which exceeds the recommended maximum of 15. Large contract interfaces force consuming modules to depend on methods they don't use. Consider splitting into focused interfaces (e.g., I{2}Queries, I{2}Commands). Your module class can implement all of them. Warning threshold: 15 methods, error threshold: 20 methods. Learn more: https://docs.simplemodule.dev/contract-design.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor ContractInterfaceTooLargeError = new( - id: "SM0013", - title: "Contract interface must be split", - messageFormat: "Contract interface '{0}' has {1} methods and must be split before the project will compile. Interfaces with more than 20 methods are not allowed. Split into focused interfaces (e.g., I{2}Queries, I{2}Commands). Your module class can implement all of them. Warning threshold: 15 methods, error threshold: 20 methods. Learn more: https://docs.simplemodule.dev/contract-design.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor MissingContractInterfaces = new( - id: "SM0014", - title: "Referenced contracts assembly has no public interfaces", - messageFormat: "Module '{0}' references '{1}' but no contract interfaces were found in that assembly. Likely causes: (1) Incompatible package version \u2014 check with 'dotnet list package --include-transitive'. (2) The Contracts project is empty or not yet built. (3) The package is corrupted \u2014 try 'dotnet nuget locals all --clear' then 'dotnet restore'. Verify that the version of {1} you're using exports the interfaces your code depends on. Learn more: https://docs.simplemodule.dev/package-compatibility.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor NoContractImplementation = new( - id: "SM0025", - title: "No implementation found for contract interface", - messageFormat: "No implementation of '{0}' found in module '{1}'. Add a public class implementing this interface.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor MultipleContractImplementations = new( - id: "SM0026", - title: "Multiple implementations of contract interface", - messageFormat: "Multiple implementations of '{0}' found in module '{1}': {2}. Only one implementation per contract interface is allowed.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor PermissionFieldNotConstString = new( - id: "SM0027", - title: "Permission field is not a const string", - messageFormat: "Permission class '{0}' must contain only public const string fields. Found field '{1}' that is not a const string.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor ContractImplementationNotPublic = new( - id: "SM0028", - title: "Contract implementation is not public", - messageFormat: "Implementation '{0}' of '{1}' must be public. The DI container cannot access internal types across assemblies.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor ContractImplementationIsAbstract = new( - id: "SM0029", - title: "Contract implementation is abstract", - messageFormat: "'{0}' implements '{1}' but is abstract. Provide a concrete implementation.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor PermissionValueBadPattern = new( - id: "SM0031", - title: "Permission value does not follow naming pattern", - messageFormat: "Permission value '{0}' in '{1}' should follow the 'Module.Action' pattern, for example 'Products.View'", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor PermissionClassNotSealed = new( - id: "SM0032", - title: "Permission class is not sealed", - messageFormat: "'{0}' implements IModulePermissions but is not sealed. Permission classes must be sealed.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor DuplicatePermissionValue = new( - id: "SM0033", - title: "Duplicate permission value", - messageFormat: "Permission value '{0}' is defined in both '{1}' and '{2}'. Each permission value must be unique.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor PermissionValueWrongPrefix = new( - id: "SM0034", - title: "Permission value prefix does not match module name", - messageFormat: "Permission '{0}' is defined in module '{1}'. Permission values should be prefixed with the owning module name.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor DtoTypeNoProperties = new( - id: "SM0035", - title: "DTO type in contracts has no public properties", - messageFormat: "'{0}' in '{1}' has no public properties. If this is not a DTO, mark it with [NoDtoGeneration].", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor InfrastructureTypeInContracts = new( - id: "SM0038", - title: "Infrastructure type in Contracts assembly", - messageFormat: "'{0}' appears to be an infrastructure type in a Contracts assembly. Infrastructure types should not be in Contracts assemblies. Mark it with [NoDtoGeneration] or move it.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor DuplicateViewPageName = new( - id: "SM0015", - title: "Duplicate view page name across modules", - messageFormat: "View page name '{0}' is registered by multiple endpoints: '{1}' (module {2}) and '{3}' (module {4}). Each IViewEndpoint must map to a unique page name. Rename one of the endpoint classes or move it to a different module.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor InterceptorDependsOnDbContext = new( - id: "SM0039", - title: "SaveChanges interceptor has transitive DbContext dependency", - messageFormat: "ISaveChangesInterceptor '{0}' in module '{1}' has a constructor parameter '{2}' whose implementation depends on a DbContext. This creates a circular dependency when ModuleDbContextOptionsBuilder resolves interceptors from DI during DbContext options construction. To fix: make the parameter optional and resolve it lazily, or remove the dependency.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor DuplicateModuleName = new( - id: "SM0040", - title: "Duplicate module name", - messageFormat: "Module name '{0}' is used by both '{1}' and '{2}'. Each module must have a unique name. Duplicate names cause route prefix conflicts, database schema collisions, and ambiguous TypeScript module grouping.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor ViewPagePrefixMismatch = new( - id: "SM0041", - title: "View page name does not match module name prefix", - messageFormat: "View endpoint '{0}' in module '{1}' maps to page '{2}', but page names should start with the module name prefix '{1}/'. This causes the React page resolver to look for the page bundle in the wrong module. Rename the endpoint class or move it to the correct module.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor ViewEndpointWithoutViewPrefix = new( - id: "SM0042", - title: "Module has view endpoints but no ViewPrefix", - messageFormat: "Module '{0}' contains {1} IViewEndpoint implementation(s) but does not define a ViewPrefix. View endpoints will not be routed correctly. Add ViewPrefix to the [Module] attribute: [Module(\"{0}\", ViewPrefix = \"/{2}\")].", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor EmptyModuleWarning = new( - id: "SM0043", - title: "Module does not override any IModule methods", - messageFormat: "Module '{0}' implements IModule but does not override any configuration methods (ConfigureServices, ConfigureMenu, etc.). This module will be discovered but has no effect. If this is intentional, add at least ConfigureServices with a comment explaining why.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor MultipleModuleOptions = new( - id: "SM0044", - title: "Multiple IModuleOptions for same module", - messageFormat: "Module '{0}' has multiple IModuleOptions implementations: '{1}' and '{2}'. Each module should have at most one options class. Only the first will be used.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor FeatureClassNotSealed = new( - id: "SM0045", - title: "Feature class is not sealed", - messageFormat: "'{0}' implements IModuleFeatures but is not sealed", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor FeatureFieldNamingViolation = new( - id: "SM0046", - title: "Feature field naming violation", - messageFormat: "Feature '{0}' in '{1}' does not follow the 'ModuleName.FeatureName' pattern", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor DuplicateFeatureName = new( - id: "SM0047", - title: "Duplicate feature name", - messageFormat: "Feature name '{0}' is defined in both '{1}' and '{2}'", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor FeatureFieldNotConstString = new( - id: "SM0048", - title: "Feature field is not a const string", - messageFormat: "Field '{0}' in feature class '{1}' must be a public const string", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor MultipleEndpointsPerFile = new( - id: "SM0049", - title: "Multiple endpoints in a single file", - messageFormat: "File '{0}' contains multiple endpoint classes ({1}). Each endpoint must be in its own file for maintainability and to match the Pages/index.ts convention.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor ModuleAssemblyNamingViolation = new( - id: "SM0052", - title: "Module assembly name does not follow naming convention", - messageFormat: "Module '{0}' is in assembly '{1}', but the assembly name must be 'SimpleModule.{0}' (or 'SimpleModule.{0}.Module' when a framework assembly with the same base name exists). Rename the project/assembly to follow the standard module naming convention.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor MissingContractsAssembly = new( - id: "SM0053", - title: "Module has no matching Contracts assembly", - messageFormat: "Module '{0}' (assembly '{1}') has no matching Contracts assembly. Every module must have a 'SimpleModule.{0}.Contracts' project with at least one public interface. Create the project and add a reference to it.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - private static readonly DiagnosticDescriptor MissingEndpointRouteConst = new( - id: "SM0054", - title: "Endpoint missing Route const field", - messageFormat: "Endpoint '{0}' does not declare a 'public const string Route' field. Add a Route const so the source generator can emit type-safe route helpers.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Info, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor EntityNotInContractsAssembly = new( - id: "SM0055", - title: "Entity class must live in a Contracts assembly", - messageFormat: "Entity '{0}' is exposed as DbSet '{1}' on '{2}' but is declared in assembly '{3}'. Entity classes must be declared in a '.Contracts' assembly so other modules can reference them type-safely through contracts. Move '{0}' to assembly '{4}' (or another '.Contracts' assembly that the module references).", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - public void Emit(SourceProductionContext context, DiscoveryData data) { // SM0002: Empty module name @@ -360,7 +18,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - EmptyModuleName, + DiagnosticDescriptors.EmptyModuleName, LocationHelper.ToLocation(module.Location), Strip(module.FullyQualifiedName) ) @@ -379,7 +37,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - DuplicateModuleName, + DiagnosticDescriptors.DuplicateModuleName, LocationHelper.ToLocation(module.Location), module.ModuleName, Strip(existingFqn), @@ -415,7 +73,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - EmptyModuleWarning, + DiagnosticDescriptors.EmptyModuleWarning, LocationHelper.ToLocation(module.Location), module.ModuleName ) @@ -442,7 +100,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - MultipleIdentityDbContexts, + DiagnosticDescriptors.MultipleIdentityDbContexts, LocationHelper.ToLocation(ctx.Location), Strip(firstIdentity.Value.FullyQualifiedName), firstIdentity.Value.ModuleName, @@ -460,7 +118,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - IdentityDbContextBadTypeArgs, + DiagnosticDescriptors.IdentityDbContextBadTypeArgs, LocationHelper.ToLocation(ctx.Location), Strip(ctx.FullyQualifiedName), ctx.ModuleName, @@ -507,7 +165,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) context.ReportDiagnostic( Diagnostic.Create( - EntityNotInContractsAssembly, + DiagnosticDescriptors.EntityNotInContractsAssembly, LocationHelper.ToLocation(dbSet.EntityLocation), Strip(dbSet.EntityFqn), dbSet.PropertyName, @@ -527,7 +185,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - EntityConfigForMissingEntity, + DiagnosticDescriptors.EntityConfigForMissingEntity, LocationHelper.ToLocation(config.Location), Strip(config.EntityFqn), Strip(config.ConfigFqn), @@ -545,7 +203,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - DuplicateEntityConfiguration, + DiagnosticDescriptors.DuplicateEntityConfiguration, LocationHelper.ToLocation(config.Location), Strip(config.EntityFqn), existing, @@ -604,7 +262,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) context.ReportDiagnostic( Diagnostic.Create( - CircularModuleDependency, + DiagnosticDescriptors.CircularModuleDependency, LocationHelper.ToLocation(cycleLoc), cycleStr, howStr, @@ -619,7 +277,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - IllegalImplementationReference, + DiagnosticDescriptors.IllegalImplementationReference, LocationHelper.ToLocation(illegal.Location), illegal.ReferencingModuleName, illegal.ReferencedModuleName, @@ -636,7 +294,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) var shortName = ExtractShortName(iface.InterfaceName); context.ReportDiagnostic( Diagnostic.Create( - ContractInterfaceTooLargeError, + DiagnosticDescriptors.ContractInterfaceTooLargeError, LocationHelper.ToLocation(iface.Location), Strip(iface.InterfaceName), iface.MethodCount, @@ -649,7 +307,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) var shortName = ExtractShortName(iface.InterfaceName); context.ReportDiagnostic( Diagnostic.Create( - ContractInterfaceTooLargeWarning, + DiagnosticDescriptors.ContractInterfaceTooLargeWarning, LocationHelper.ToLocation(iface.Location), Strip(iface.InterfaceName), iface.MethodCount, @@ -684,7 +342,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) context.ReportDiagnostic( Diagnostic.Create( - MissingContractInterfaces, + DiagnosticDescriptors.MissingContractInterfaces, LocationHelper.ToLocation(depModuleLoc), dep.ModuleName, dep.ContractsAssemblyName @@ -714,7 +372,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - ContractImplementationNotPublic, + DiagnosticDescriptors.ContractImplementationNotPublic, LocationHelper.ToLocation(impl.Location), Strip(impl.ImplementationFqn), Strip(impl.InterfaceFqn) @@ -726,7 +384,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - ContractImplementationIsAbstract, + DiagnosticDescriptors.ContractImplementationIsAbstract, LocationHelper.ToLocation(impl.Location), Strip(impl.ImplementationFqn), Strip(impl.InterfaceFqn) @@ -747,7 +405,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) context.ReportDiagnostic( Diagnostic.Create( - NoContractImplementation, + DiagnosticDescriptors.NoContractImplementation, LocationHelper.ToLocation(iface.Location), Strip(iface.InterfaceName), moduleName @@ -774,7 +432,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) context.ReportDiagnostic( Diagnostic.Create( - MultipleContractImplementations, + DiagnosticDescriptors.MultipleContractImplementations, LocationHelper.ToLocation(validImpls[1].Location), Strip(kvp.Key), validImpls[0].ModuleName, @@ -797,7 +455,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - PermissionClassNotSealed, + DiagnosticDescriptors.PermissionClassNotSealed, LocationHelper.ToLocation(perm.Location), permCleanName ) @@ -811,7 +469,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - PermissionFieldNotConstString, + DiagnosticDescriptors.PermissionFieldNotConstString, LocationHelper.ToLocation(field.Location), permCleanName, field.FieldName @@ -831,7 +489,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - PermissionValueBadPattern, + DiagnosticDescriptors.PermissionValueBadPattern, LocationHelper.ToLocation(field.Location), field.Value, permCleanName @@ -847,7 +505,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - PermissionValueWrongPrefix, + DiagnosticDescriptors.PermissionValueWrongPrefix, LocationHelper.ToLocation(field.Location), field.Value, perm.ModuleName @@ -861,7 +519,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - DuplicatePermissionValue, + DiagnosticDescriptors.DuplicatePermissionValue, LocationHelper.ToLocation(field.Location), field.Value, existingOwner, @@ -899,7 +557,12 @@ public void Emit(SourceProductionContext context, DiscoveryData data) contractsIdx >= 0 ? fqn.Substring(0, contractsIdx + ".Contracts".Length) : fqn; context.ReportDiagnostic( - Diagnostic.Create(DtoTypeNoProperties, Location.None, fqn, contractsAsm) + Diagnostic.Create( + DiagnosticDescriptors.DtoTypeNoProperties, + Location.None, + fqn, + contractsAsm + ) ); } } @@ -914,7 +577,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - InfrastructureTypeInContracts, + DiagnosticDescriptors.InfrastructureTypeInContracts, Location.None, Strip(dto.FullyQualifiedName) ) @@ -932,7 +595,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - DuplicateViewPageName, + DiagnosticDescriptors.DuplicateViewPageName, LocationHelper.ToLocation(view.Location), view.Page, Strip(existing.EndpointFqn), @@ -962,7 +625,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - ViewPagePrefixMismatch, + DiagnosticDescriptors.ViewPagePrefixMismatch, LocationHelper.ToLocation(view.Location), Strip(view.FullyQualifiedName), module.ModuleName, @@ -981,7 +644,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) #pragma warning disable CA1308 // Route prefixes are conventionally lowercase context.ReportDiagnostic( Diagnostic.Create( - ViewEndpointWithoutViewPrefix, + DiagnosticDescriptors.ViewEndpointWithoutViewPrefix, LocationHelper.ToLocation(module.Location), module.ModuleName, module.Views.Length, @@ -1003,7 +666,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - InterceptorDependsOnDbContext, + DiagnosticDescriptors.InterceptorDependsOnDbContext, LocationHelper.ToLocation(interceptor.Location), Strip(interceptor.FullyQualifiedName), interceptor.ModuleName, @@ -1024,7 +687,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - MultipleModuleOptions, + DiagnosticDescriptors.MultipleModuleOptions, LocationHelper.ToLocation(kvp.Value[1].Location), kvp.Key, Strip(kvp.Value[0].FullyQualifiedName), @@ -1046,7 +709,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - FeatureClassNotSealed, + DiagnosticDescriptors.FeatureClassNotSealed, LocationHelper.ToLocation(feat.Location), featCleanName ) @@ -1060,7 +723,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - FeatureFieldNotConstString, + DiagnosticDescriptors.FeatureFieldNotConstString, LocationHelper.ToLocation(field.Location), field.FieldName, featCleanName @@ -1074,7 +737,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - FeatureFieldNamingViolation, + DiagnosticDescriptors.FeatureFieldNamingViolation, LocationHelper.ToLocation(field.Location), field.Value, featCleanName @@ -1089,7 +752,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - DuplicateFeatureName, + DiagnosticDescriptors.DuplicateFeatureName, LocationHelper.ToLocation(field.Location), field.Value, Strip(existingOwner), @@ -1134,7 +797,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) context.ReportDiagnostic( Diagnostic.Create( - MultipleEndpointsPerFile, + DiagnosticDescriptors.MultipleEndpointsPerFile, LocationHelper.ToLocation(kvp.Value[1].Loc), fileName, names @@ -1188,7 +851,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - ModuleAssemblyNamingViolation, + DiagnosticDescriptors.ModuleAssemblyNamingViolation, LocationHelper.ToLocation(module.Location), module.ModuleName, module.AssemblyName @@ -1209,7 +872,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - MissingContractsAssembly, + DiagnosticDescriptors.MissingContractsAssembly, LocationHelper.ToLocation(module.Location), module.ModuleName, module.AssemblyName @@ -1224,7 +887,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - MissingEndpointRouteConst, + DiagnosticDescriptors.MissingEndpointRouteConst, Location.None, Strip(endpoint.FullyQualifiedName) ) @@ -1238,7 +901,7 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { context.ReportDiagnostic( Diagnostic.Create( - MissingEndpointRouteConst, + DiagnosticDescriptors.MissingEndpointRouteConst, LocationHelper.ToLocation(view.Location), Strip(view.FullyQualifiedName) ) diff --git a/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.cs b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.cs new file mode 100644 index 00000000..d131830b --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.cs @@ -0,0 +1,348 @@ +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal static class DiagnosticDescriptors +{ + 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 + ); + + internal 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 + ); + + internal 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 + ); + + internal 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 + ); + + internal 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 + ); + + internal 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 to handle those events. Learn more: https://docs.simplemodule.dev/module-dependencies.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor IllegalImplementationReference = new( + id: "SM0011", + title: "Module directly references another module's implementation", + messageFormat: "Module '{0}' directly references module '{1}' implementation assembly '{2}'. Modules must only depend on each other through Contracts packages. This creates tight coupling \u2014 internal changes in {1} can break {0} at compile time or runtime. To fix: (1) Remove the reference to '{2}'. (2) Add a reference to '{1}.Contracts' instead. (3) Replace any usage of internal {1} types with their contract interfaces. Learn more: https://docs.simplemodule.dev/module-contracts.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor ContractInterfaceTooLargeWarning = new( + id: "SM0012", + title: "Contract interface has too many methods", + messageFormat: "Contract interface '{0}' has {1} methods, which exceeds the recommended maximum of 15. Large contract interfaces force consuming modules to depend on methods they don't use. Consider splitting into focused interfaces (e.g., I{2}Queries, I{2}Commands). Your module class can implement all of them. Warning threshold: 15 methods, error threshold: 20 methods. Learn more: https://docs.simplemodule.dev/contract-design.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor ContractInterfaceTooLargeError = new( + id: "SM0013", + title: "Contract interface must be split", + messageFormat: "Contract interface '{0}' has {1} methods and must be split before the project will compile. Interfaces with more than 20 methods are not allowed. Split into focused interfaces (e.g., I{2}Queries, I{2}Commands). Your module class can implement all of them. Warning threshold: 15 methods, error threshold: 20 methods. Learn more: https://docs.simplemodule.dev/contract-design.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor MissingContractInterfaces = new( + id: "SM0014", + title: "Referenced contracts assembly has no public interfaces", + messageFormat: "Module '{0}' references '{1}' but no contract interfaces were found in that assembly. Likely causes: (1) Incompatible package version \u2014 check with 'dotnet list package --include-transitive'. (2) The Contracts project is empty or not yet built. (3) The package is corrupted \u2014 try 'dotnet nuget locals all --clear' then 'dotnet restore'. Verify that the version of {1} you're using exports the interfaces your code depends on. Learn more: https://docs.simplemodule.dev/package-compatibility.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor DuplicateViewPageName = new( + id: "SM0015", + title: "Duplicate view page name across modules", + messageFormat: "View page name '{0}' is registered by multiple endpoints: '{1}' (module {2}) and '{3}' (module {4}). Each IViewEndpoint must map to a unique page name. Rename one of the endpoint classes or move it to a different module.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor NoContractImplementation = new( + id: "SM0025", + title: "No implementation found for contract interface", + messageFormat: "No implementation of '{0}' found in module '{1}'. Add a public class implementing this interface.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor MultipleContractImplementations = new( + id: "SM0026", + title: "Multiple implementations of contract interface", + messageFormat: "Multiple implementations of '{0}' found in module '{1}': {2}. Only one implementation per contract interface is allowed.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor PermissionFieldNotConstString = new( + id: "SM0027", + title: "Permission field is not a const string", + messageFormat: "Permission class '{0}' must contain only public const string fields. Found field '{1}' that is not a const string.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor ContractImplementationNotPublic = new( + id: "SM0028", + title: "Contract implementation is not public", + messageFormat: "Implementation '{0}' of '{1}' must be public. The DI container cannot access internal types across assemblies.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor ContractImplementationIsAbstract = new( + id: "SM0029", + title: "Contract implementation is abstract", + messageFormat: "'{0}' implements '{1}' but is abstract. Provide a concrete implementation.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor PermissionValueBadPattern = new( + id: "SM0031", + title: "Permission value does not follow naming pattern", + messageFormat: "Permission value '{0}' in '{1}' should follow the 'Module.Action' pattern, for example 'Products.View'", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor PermissionClassNotSealed = new( + id: "SM0032", + title: "Permission class is not sealed", + messageFormat: "'{0}' implements IModulePermissions but is not sealed. Permission classes must be sealed.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor DuplicatePermissionValue = new( + id: "SM0033", + title: "Duplicate permission value", + messageFormat: "Permission value '{0}' is defined in both '{1}' and '{2}'. Each permission value must be unique.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor PermissionValueWrongPrefix = new( + id: "SM0034", + title: "Permission value prefix does not match module name", + messageFormat: "Permission '{0}' is defined in module '{1}'. Permission values should be prefixed with the owning module name.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor DtoTypeNoProperties = new( + id: "SM0035", + title: "DTO type in contracts has no public properties", + messageFormat: "'{0}' in '{1}' has no public properties. If this is not a DTO, mark it with [NoDtoGeneration].", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor InfrastructureTypeInContracts = new( + id: "SM0038", + title: "Infrastructure type in Contracts assembly", + messageFormat: "'{0}' appears to be an infrastructure type in a Contracts assembly. Infrastructure types should not be in Contracts assemblies. Mark it with [NoDtoGeneration] or move it.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor InterceptorDependsOnDbContext = new( + id: "SM0039", + title: "SaveChanges interceptor has transitive DbContext dependency", + messageFormat: "ISaveChangesInterceptor '{0}' in module '{1}' has a constructor parameter '{2}' whose implementation depends on a DbContext. This creates a circular dependency when ModuleDbContextOptionsBuilder resolves interceptors from DI during DbContext options construction. To fix: make the parameter optional and resolve it lazily, or remove the dependency.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor DuplicateModuleName = new( + id: "SM0040", + title: "Duplicate module name", + messageFormat: "Module name '{0}' is used by both '{1}' and '{2}'. Each module must have a unique name. Duplicate names cause route prefix conflicts, database schema collisions, and ambiguous TypeScript module grouping.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor ViewPagePrefixMismatch = new( + id: "SM0041", + title: "View page name does not match module name prefix", + messageFormat: "View endpoint '{0}' in module '{1}' maps to page '{2}', but page names should start with the module name prefix '{1}/'. This causes the React page resolver to look for the page bundle in the wrong module. Rename the endpoint class or move it to the correct module.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor ViewEndpointWithoutViewPrefix = new( + id: "SM0042", + title: "Module has view endpoints but no ViewPrefix", + messageFormat: "Module '{0}' contains {1} IViewEndpoint implementation(s) but does not define a ViewPrefix. View endpoints will not be routed correctly. Add ViewPrefix to the [Module] attribute: [Module(\"{0}\", ViewPrefix = \"/{2}\")].", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor EmptyModuleWarning = new( + id: "SM0043", + title: "Module does not override any IModule methods", + messageFormat: "Module '{0}' implements IModule but does not override any configuration methods (ConfigureServices, ConfigureMenu, etc.). This module will be discovered but has no effect. If this is intentional, add at least ConfigureServices with a comment explaining why.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor MultipleModuleOptions = new( + id: "SM0044", + title: "Multiple IModuleOptions for same module", + messageFormat: "Module '{0}' has multiple IModuleOptions implementations: '{1}' and '{2}'. Each module should have at most one options class. Only the first will be used.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor FeatureClassNotSealed = new( + id: "SM0045", + title: "Feature class is not sealed", + messageFormat: "'{0}' implements IModuleFeatures but is not sealed", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor FeatureFieldNamingViolation = new( + id: "SM0046", + title: "Feature field naming violation", + messageFormat: "Feature '{0}' in '{1}' does not follow the 'ModuleName.FeatureName' pattern", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor DuplicateFeatureName = new( + id: "SM0047", + title: "Duplicate feature name", + messageFormat: "Feature name '{0}' is defined in both '{1}' and '{2}'", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor FeatureFieldNotConstString = new( + id: "SM0048", + title: "Feature field is not a const string", + messageFormat: "Field '{0}' in feature class '{1}' must be a public const string", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor MultipleEndpointsPerFile = new( + id: "SM0049", + title: "Multiple endpoints in a single file", + messageFormat: "File '{0}' contains multiple endpoint classes ({1}). Each endpoint must be in its own file for maintainability and to match the Pages/index.ts convention.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor ModuleAssemblyNamingViolation = new( + id: "SM0052", + title: "Module assembly name does not follow naming convention", + messageFormat: "Module '{0}' is in assembly '{1}', but the assembly name must be 'SimpleModule.{0}' (or 'SimpleModule.{0}.Module' when a framework assembly with the same base name exists). Rename the project/assembly to follow the standard module naming convention.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor MissingContractsAssembly = new( + id: "SM0053", + title: "Module has no matching Contracts assembly", + messageFormat: "Module '{0}' (assembly '{1}') has no matching Contracts assembly. Every module must have a 'SimpleModule.{0}.Contracts' project with at least one public interface. Create the project and add a reference to it.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor MissingEndpointRouteConst = new( + id: "SM0054", + title: "Endpoint missing Route const field", + messageFormat: "Endpoint '{0}' does not declare a 'public const string Route' field. Add a Route const so the source generator can emit type-safe route helpers.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor EntityNotInContractsAssembly = new( + id: "SM0055", + title: "Entity class must live in a Contracts assembly", + messageFormat: "Entity '{0}' is exposed as DbSet '{1}' on '{2}' but is declared in assembly '{3}'. Entity classes must be declared in a '.Contracts' assembly so other modules can reference them type-safely through contracts. Move '{0}' to assembly '{4}' (or another '.Contracts' assembly that the module references).", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); +} diff --git a/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs b/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs index 8a27cf48..e0960e08 100644 --- a/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs @@ -92,7 +92,7 @@ string ModuleName context.ReportDiagnostic( Diagnostic.Create( - DiagnosticEmitter.DuplicateDbSetPropertyName, + DiagnosticDescriptors.DuplicateDbSetPropertyName, LocationHelper.ToLocation(dbCtxLoc), entry.PropertyName, existing.ModuleName, From 07d1acb76d081398a1e32c6b890f3582efdb2012 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:50:18 +0200 Subject: [PATCH 25/38] refactor(generator): extract ModuleChecks (SM0002/0040/0043) --- .../Emitters/DiagnosticEmitter.cs | 70 +--------------- .../Emitters/Diagnostics/ModuleChecks.cs | 83 +++++++++++++++++++ 2 files changed, 84 insertions(+), 69 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/Diagnostics/ModuleChecks.cs diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index bebc7917..b632bb15 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -11,75 +11,7 @@ internal sealed class DiagnosticEmitter : IEmitter { public void Emit(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), - Strip(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, - Strip(existingFqn), - Strip(module.FullyQualifiedName) - ) - ); - } - else - { - seenModuleNames[module.ModuleName] = module.FullyQualifiedName; - } - } - - // SM0043: Empty module (no IModule methods overridden) - var moduleNamesWithDbContext = new HashSet( - data.DbContexts.Select(db => db.ModuleName), - StringComparer.Ordinal - ); - 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 - ) - ); - } - } + ModuleChecks.Run(context, data); // SM0004: DbContext with no DbSets — silently skipped. // Some DbContexts (e.g., OpenIddict) manage tables internally without public DbSet diff --git a/framework/SimpleModule.Generator/Emitters/Diagnostics/ModuleChecks.cs b/framework/SimpleModule.Generator/Emitters/Diagnostics/ModuleChecks.cs new file mode 100644 index 00000000..75fde0b7 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/Diagnostics/ModuleChecks.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Linq; +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), + Strip(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, + Strip(existingFqn), + Strip(module.FullyQualifiedName) + ) + ); + } + else + { + seenModuleNames[module.ModuleName] = module.FullyQualifiedName; + } + } + + // SM0043: Empty module (no IModule methods overridden) + var moduleNamesWithDbContext = new HashSet( + data.DbContexts.Select(db => db.ModuleName), + System.StringComparer.Ordinal + ); + 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 + ) + ); + } + } + } + + private static string Strip(string fqn) => TypeMappingHelpers.StripGlobalPrefix(fqn); +} From 2f8b5db37ec016d367d4e961f0530240f77f8dbc Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:52:50 +0200 Subject: [PATCH 26/38] refactor(generator): extract DbContextChecks (SM0003/0005/0006/0007/0055) --- .../Emitters/DiagnosticEmitter.cs | 133 +--------------- .../Emitters/Diagnostics/DbContextChecks.cs | 145 ++++++++++++++++++ 2 files changed, 146 insertions(+), 132 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/Diagnostics/DbContextChecks.cs diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index b632bb15..3ebd119a 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -12,143 +12,12 @@ internal sealed class DiagnosticEmitter : IEmitter public void Emit(SourceProductionContext context, DiscoveryData data) { ModuleChecks.Run(context, data); + DbContextChecks.Run(context, data); // SM0004: DbContext with no DbSets — silently skipped. // Some DbContexts (e.g., OpenIddict) manage tables internally without public DbSet // properties. These are excluded from the unified HostDbContext but are not an error. - // SM0003: Multiple IdentityDbContexts - DbContextInfoRecord? firstIdentity = null; - foreach (var ctx in data.DbContexts) - { - if (!ctx.IsIdentityDbContext) - continue; - - if (firstIdentity is null) - { - firstIdentity = ctx; - } - else - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.MultipleIdentityDbContexts, - LocationHelper.ToLocation(ctx.Location), - Strip(firstIdentity.Value.FullyQualifiedName), - firstIdentity.Value.ModuleName, - Strip(ctx.FullyQualifiedName), - ctx.ModuleName - ) - ); - } - } - - // SM0005: IdentityDbContext with wrong type args - foreach (var ctx in data.DbContexts) - { - if (ctx.IsIdentityDbContext && string.IsNullOrEmpty(ctx.IdentityUserTypeFqn)) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.IdentityDbContextBadTypeArgs, - LocationHelper.ToLocation(ctx.Location), - Strip(ctx.FullyQualifiedName), - ctx.ModuleName, - 0 - ) - ); - } - } - - // SM0055: Entity classes must live in a .Contracts assembly. - // Walks every DbSet in the same pass that also collects EntityFqns - // for SM0006 below, so we only iterate data.DbContexts once. - var allEntityFqns = new HashSet(); - foreach (var ctx in data.DbContexts) - { - foreach (var dbSet in ctx.DbSets) - { - allEntityFqns.Add(dbSet.EntityFqn); - - // Skip entities we can't flag: IdentityDbContext external types, - // metadata-only symbols (no source location), and anything that - // lives outside the SimpleModule.* assembly family. - if (ctx.IsIdentityDbContext) - continue; - if (dbSet.EntityLocation is null) - continue; - if ( - !dbSet.EntityAssemblyName.StartsWith( - AssemblyConventions.FrameworkPrefix, - StringComparison.Ordinal - ) - ) - continue; - if ( - dbSet.EntityAssemblyName.EndsWith( - AssemblyConventions.ContractsSuffix, - StringComparison.Ordinal - ) - ) - continue; - - var expectedContractsAssembly = - AssemblyConventions.GetExpectedContractsAssemblyName(dbSet.EntityAssemblyName); - - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.EntityNotInContractsAssembly, - LocationHelper.ToLocation(dbSet.EntityLocation), - Strip(dbSet.EntityFqn), - dbSet.PropertyName, - Strip(ctx.FullyQualifiedName), - dbSet.EntityAssemblyName, - expectedContractsAssembly - ) - ); - } - } - - // SM0006: Entity config for entity not in any DbSet - // (allEntityFqns was populated above during the SM0055 pass) - foreach (var config in data.EntityConfigs) - { - if (!allEntityFqns.Contains(config.EntityFqn)) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.EntityConfigForMissingEntity, - LocationHelper.ToLocation(config.Location), - Strip(config.EntityFqn), - Strip(config.ConfigFqn), - config.ModuleName - ) - ); - } - } - - // SM0007: Duplicate EntityTypeConfiguration for same entity - var entityConfigOwners = new Dictionary(); - foreach (var config in data.EntityConfigs) - { - if (entityConfigOwners.TryGetValue(config.EntityFqn, out var existing)) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.DuplicateEntityConfiguration, - LocationHelper.ToLocation(config.Location), - Strip(config.EntityFqn), - existing, - Strip(config.ConfigFqn) - ) - ); - } - else - { - entityConfigOwners[config.EntityFqn] = Strip(config.ConfigFqn); - } - } - // SM0010: Circular module dependency var (_, sortResult) = TopologicalSort.SortModulesWithResult(data); diff --git a/framework/SimpleModule.Generator/Emitters/Diagnostics/DbContextChecks.cs b/framework/SimpleModule.Generator/Emitters/Diagnostics/DbContextChecks.cs new file mode 100644 index 00000000..ab0db69b --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/Diagnostics/DbContextChecks.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal static class DbContextChecks +{ + internal static void Run(SourceProductionContext context, DiscoveryData data) + { + // SM0003: Multiple IdentityDbContexts + DbContextInfoRecord? firstIdentity = null; + foreach (var ctx in data.DbContexts) + { + if (!ctx.IsIdentityDbContext) + continue; + + if (firstIdentity is null) + { + firstIdentity = ctx; + } + else + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.MultipleIdentityDbContexts, + LocationHelper.ToLocation(ctx.Location), + Strip(firstIdentity.Value.FullyQualifiedName), + firstIdentity.Value.ModuleName, + Strip(ctx.FullyQualifiedName), + ctx.ModuleName + ) + ); + } + } + + // SM0005: IdentityDbContext with wrong type args + foreach (var ctx in data.DbContexts) + { + if (ctx.IsIdentityDbContext && string.IsNullOrEmpty(ctx.IdentityUserTypeFqn)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.IdentityDbContextBadTypeArgs, + LocationHelper.ToLocation(ctx.Location), + Strip(ctx.FullyQualifiedName), + ctx.ModuleName, + 0 + ) + ); + } + } + + // SM0055: Entity classes must live in a .Contracts assembly. + // Walks every DbSet in the same pass that also collects EntityFqns + // for SM0006 below, so we only iterate data.DbContexts once. + var allEntityFqns = new HashSet(); + foreach (var ctx in data.DbContexts) + { + foreach (var dbSet in ctx.DbSets) + { + allEntityFqns.Add(dbSet.EntityFqn); + + // Skip entities we can't flag: IdentityDbContext external types, + // metadata-only symbols (no source location), and anything that + // lives outside the SimpleModule.* assembly family. + if (ctx.IsIdentityDbContext) + continue; + if (dbSet.EntityLocation is null) + continue; + if ( + !dbSet.EntityAssemblyName.StartsWith( + AssemblyConventions.FrameworkPrefix, + StringComparison.Ordinal + ) + ) + continue; + if ( + dbSet.EntityAssemblyName.EndsWith( + AssemblyConventions.ContractsSuffix, + StringComparison.Ordinal + ) + ) + continue; + + var expectedContractsAssembly = + AssemblyConventions.GetExpectedContractsAssemblyName(dbSet.EntityAssemblyName); + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.EntityNotInContractsAssembly, + LocationHelper.ToLocation(dbSet.EntityLocation), + Strip(dbSet.EntityFqn), + dbSet.PropertyName, + Strip(ctx.FullyQualifiedName), + dbSet.EntityAssemblyName, + expectedContractsAssembly + ) + ); + } + } + + // SM0006: Entity config for entity not in any DbSet + // (allEntityFqns was populated above during the SM0055 pass) + foreach (var config in data.EntityConfigs) + { + if (!allEntityFqns.Contains(config.EntityFqn)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.EntityConfigForMissingEntity, + LocationHelper.ToLocation(config.Location), + Strip(config.EntityFqn), + Strip(config.ConfigFqn), + config.ModuleName + ) + ); + } + } + + // SM0007: Duplicate EntityTypeConfiguration for same entity + var entityConfigOwners = new Dictionary(); + foreach (var config in data.EntityConfigs) + { + if (entityConfigOwners.TryGetValue(config.EntityFqn, out var existing)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.DuplicateEntityConfiguration, + LocationHelper.ToLocation(config.Location), + Strip(config.EntityFqn), + existing, + Strip(config.ConfigFqn) + ) + ); + } + else + { + entityConfigOwners[config.EntityFqn] = Strip(config.ConfigFqn); + } + } + } + + private static string Strip(string fqn) => TypeMappingHelpers.StripGlobalPrefix(fqn); +} From 24a31da915644fa413de930ac0268cc0b6bfe2ab Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:54:38 +0200 Subject: [PATCH 27/38] refactor(generator): extract DependencyChecks (SM0010/0011) --- .../Emitters/DiagnosticEmitter.cs | 70 +--------------- .../Emitters/Diagnostics/DependencyChecks.cs | 79 +++++++++++++++++++ 2 files changed, 80 insertions(+), 69 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/Diagnostics/DependencyChecks.cs diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index 3ebd119a..1e427f92 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -13,80 +13,12 @@ public void Emit(SourceProductionContext context, DiscoveryData data) { ModuleChecks.Run(context, data); DbContextChecks.Run(context, data); + DependencyChecks.Run(context, data); // SM0004: DbContext with no DbSets — silently skipped. // Some DbContexts (e.g., OpenIddict) manage tables internally without public DbSet // properties. These are excluded from the unified HostDbContext but are not an error. - // SM0010: Circular module dependency - var (_, sortResult) = TopologicalSort.SortModulesWithResult(data); - - if (!sortResult.IsSuccess && sortResult.Cycle.Length > 0) - { - // Build cycle string: "A → B → C → A" - var cycleNodes = new List(); - foreach (var c in sortResult.Cycle) - cycleNodes.Add(c); - cycleNodes.Add(sortResult.Cycle[0]); // close the loop - var cycleStr = string.Join(" \u2192 ", cycleNodes); - - // Build "how it happened" string - var cycleSet = new HashSet(); - foreach (var c in sortResult.Cycle) - cycleSet.Add(c); - - var howParts = new List(); - foreach (var dep in data.Dependencies) - { - if (cycleSet.Contains(dep.ModuleName) && cycleSet.Contains(dep.DependsOnModuleName)) - { - howParts.Add( - dep.ModuleName + " references " + dep.ContractsAssemblyName + ". " - ); - } - } - var howStr = string.Join("", howParts); - - var first = sortResult.Cycle[0]; - var second = sortResult.Cycle.Length > 1 ? sortResult.Cycle[1] : first; - - // Find location of the first module in the cycle - SourceLocationRecord? cycleLoc = null; - foreach (var module in data.Modules) - { - if (module.ModuleName == first) - { - cycleLoc = module.Location; - break; - } - } - - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.CircularModuleDependency, - LocationHelper.ToLocation(cycleLoc), - cycleStr, - howStr, - first, - second - ) - ); - } - - // SM0011: Illegal implementation references - foreach (var illegal in data.IllegalReferences) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.IllegalImplementationReference, - LocationHelper.ToLocation(illegal.Location), - illegal.ReferencingModuleName, - illegal.ReferencedModuleName, - illegal.ReferencedAssemblyName - ) - ); - } - // SM0012/SM0013: Contract interface size foreach (var iface in data.ContractInterfaces) { diff --git a/framework/SimpleModule.Generator/Emitters/Diagnostics/DependencyChecks.cs b/framework/SimpleModule.Generator/Emitters/Diagnostics/DependencyChecks.cs new file mode 100644 index 00000000..35286dac --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/Diagnostics/DependencyChecks.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal static class DependencyChecks +{ + internal static void Run(SourceProductionContext context, DiscoveryData data) + { + // SM0010: Circular module dependency + var (_, sortResult) = TopologicalSort.SortModulesWithResult(data); + + if (!sortResult.IsSuccess && sortResult.Cycle.Length > 0) + { + // Build cycle string: "A → B → C → A" + var cycleNodes = new List(); + foreach (var c in sortResult.Cycle) + cycleNodes.Add(c); + cycleNodes.Add(sortResult.Cycle[0]); // close the loop + var cycleStr = string.Join(" \u2192 ", cycleNodes); + + // Build "how it happened" string + var cycleSet = new HashSet(); + foreach (var c in sortResult.Cycle) + cycleSet.Add(c); + + var howParts = new List(); + foreach (var dep in data.Dependencies) + { + if (cycleSet.Contains(dep.ModuleName) && cycleSet.Contains(dep.DependsOnModuleName)) + { + howParts.Add( + dep.ModuleName + " references " + dep.ContractsAssemblyName + ". " + ); + } + } + var howStr = string.Join("", howParts); + + var first = sortResult.Cycle[0]; + var second = sortResult.Cycle.Length > 1 ? sortResult.Cycle[1] : first; + + // Find location of the first module in the cycle + SourceLocationRecord? cycleLoc = null; + foreach (var module in data.Modules) + { + if (module.ModuleName == first) + { + cycleLoc = module.Location; + break; + } + } + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.CircularModuleDependency, + LocationHelper.ToLocation(cycleLoc), + cycleStr, + howStr, + first, + second + ) + ); + } + + // SM0011: Illegal implementation references + foreach (var illegal in data.IllegalReferences) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.IllegalImplementationReference, + LocationHelper.ToLocation(illegal.Location), + illegal.ReferencingModuleName, + illegal.ReferencedModuleName, + illegal.ReferencedAssemblyName + ) + ); + } + } +} From e4cb8166e9bd63491a1d4dcb2e31c4c46707e0cc Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:57:20 +0200 Subject: [PATCH 28/38] refactor(generator): extract ContractAndDtoChecks --- .../Emitters/DiagnosticEmitter.cs | 220 +---------------- .../Diagnostics/ContractAndDtoChecks.cs | 231 ++++++++++++++++++ 2 files changed, 232 insertions(+), 219 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/Diagnostics/ContractAndDtoChecks.cs diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index 1e427f92..527817bd 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -14,167 +14,12 @@ public void Emit(SourceProductionContext context, DiscoveryData data) ModuleChecks.Run(context, data); DbContextChecks.Run(context, data); DependencyChecks.Run(context, data); + ContractAndDtoChecks.Run(context, data); // SM0004: DbContext with no DbSets — silently skipped. // Some DbContexts (e.g., OpenIddict) manage tables internally without public DbSet // properties. These are excluded from the unified HostDbContext but are not an error. - // SM0012/SM0013: Contract interface size - foreach (var iface in data.ContractInterfaces) - { - if (iface.MethodCount > 20) - { - var shortName = ExtractShortName(iface.InterfaceName); - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.ContractInterfaceTooLargeError, - LocationHelper.ToLocation(iface.Location), - Strip(iface.InterfaceName), - iface.MethodCount, - shortName - ) - ); - } - else if (iface.MethodCount >= 15) - { - var shortName = ExtractShortName(iface.InterfaceName); - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.ContractInterfaceTooLargeWarning, - LocationHelper.ToLocation(iface.Location), - Strip(iface.InterfaceName), - iface.MethodCount, - shortName - ) - ); - } - } - - // SM0014: Missing contract interfaces in referenced contracts assemblies - var contractsWithInterfaces = new HashSet(); - foreach (var iface in data.ContractInterfaces) - contractsWithInterfaces.Add(iface.ContractsAssemblyName); - - // Deduplicate: only report once per (module, contracts assembly) pair - var reported = new HashSet(); - foreach (var dep in data.Dependencies) - { - var key = dep.ModuleName + "|" + dep.ContractsAssemblyName; - if (!contractsWithInterfaces.Contains(dep.ContractsAssemblyName) && reported.Add(key)) - { - // Find the module's location for this diagnostic - SourceLocationRecord? depModuleLoc = null; - foreach (var module in data.Modules) - { - if (module.ModuleName == dep.ModuleName) - { - depModuleLoc = module.Location; - break; - } - } - - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.MissingContractInterfaces, - LocationHelper.ToLocation(depModuleLoc), - dep.ModuleName, - dep.ContractsAssemblyName - ) - ); - } - } - - // SM0025/SM0026/SM0028/SM0029: Contract implementation diagnostics - // Group all implementations by interface FQN - var implsByInterface = new Dictionary>(); - foreach (var impl in data.ContractImplementations) - { - if (!implsByInterface.TryGetValue(impl.InterfaceFqn, out var list)) - { - list = new List(); - implsByInterface[impl.InterfaceFqn] = list; - } - list.Add(impl); - } - - // SM0028: Non-public implementations - // SM0029: Abstract implementations - foreach (var impl in data.ContractImplementations) - { - if (!impl.IsPublic) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.ContractImplementationNotPublic, - LocationHelper.ToLocation(impl.Location), - Strip(impl.ImplementationFqn), - Strip(impl.InterfaceFqn) - ) - ); - } - - if (impl.IsAbstract) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.ContractImplementationIsAbstract, - LocationHelper.ToLocation(impl.Location), - Strip(impl.ImplementationFqn), - Strip(impl.InterfaceFqn) - ) - ); - } - } - - // SM0025: No implementation for a contract interface - foreach (var iface in data.ContractInterfaces) - { - if (!implsByInterface.ContainsKey(iface.InterfaceName)) - { - // Derive module name from contracts assembly name - var moduleName = iface.ContractsAssemblyName; - if (moduleName.EndsWith(".Contracts", System.StringComparison.Ordinal)) - moduleName = moduleName.Substring(0, moduleName.Length - ".Contracts".Length); - - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.NoContractImplementation, - LocationHelper.ToLocation(iface.Location), - Strip(iface.InterfaceName), - moduleName - ) - ); - } - } - - // SM0026: Multiple valid implementations for the same interface - foreach (var kvp in implsByInterface) - { - var validImpls = new List(); - foreach (var impl in kvp.Value) - { - if (impl.IsPublic && !impl.IsAbstract) - validImpls.Add(impl); - } - - if (validImpls.Count > 1) - { - var names = new List(); - foreach (var impl in validImpls) - names.Add(Strip(impl.ImplementationFqn)); - - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.MultipleContractImplementations, - LocationHelper.ToLocation(validImpls[1].Location), - Strip(kvp.Key), - validImpls[0].ModuleName, - string.Join(", ", names) - ) - ); - } - } - // SM0027/SM0031/SM0032/SM0033/SM0034: Permission diagnostics // Track permission values for duplicate detection (value -> class FQN) var permissionValueOwners = new Dictionary(); @@ -267,57 +112,6 @@ public void Emit(SourceProductionContext context, DiscoveryData data) } } - // SM0035: DTO type in contracts with no public properties - // Exclude permission and feature classes — they only have const string fields, not properties - var permissionClassFqns = new HashSet(); - foreach (var perm in data.PermissionClasses) - permissionClassFqns.Add(perm.FullyQualifiedName); - foreach (var feat in data.FeatureClasses) - permissionClassFqns.Add(feat.FullyQualifiedName); - - foreach (var dto in data.DtoTypes) - { - if ( - dto.FullyQualifiedName.Contains(".Contracts.") - && dto.Properties.Length == 0 - && !permissionClassFqns.Contains(dto.FullyQualifiedName) - ) - { - // Extract assembly/namespace context from FQN - var fqn = Strip(dto.FullyQualifiedName); - var contractsIdx = fqn.IndexOf(".Contracts.", System.StringComparison.Ordinal); - var contractsAsm = - contractsIdx >= 0 ? fqn.Substring(0, contractsIdx + ".Contracts".Length) : fqn; - - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.DtoTypeNoProperties, - Location.None, - fqn, - contractsAsm - ) - ); - } - } - - // SM0038: Infrastructure type (DbContext subclass) in Contracts assembly - foreach (var dto in data.DtoTypes) - { - if ( - dto.FullyQualifiedName.Contains(".Contracts.") - && dto.FullyQualifiedName.Contains("DbContext") - ) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.InfrastructureTypeInContracts, - Location.None, - Strip(dto.FullyQualifiedName) - ) - ); - } - } - // SM0015: Duplicate view page name across modules var seenPages = new Dictionary(); foreach (var module in data.Modules) @@ -646,16 +440,4 @@ public void Emit(SourceProductionContext context, DiscoveryData data) } private static string Strip(string fqn) => TypeMappingHelpers.StripGlobalPrefix(fqn); - - private static string ExtractShortName(string interfaceName) - { - var name = Strip(interfaceName); - if (name.Contains(".")) - name = name.Substring(name.LastIndexOf('.') + 1); - if (name.StartsWith("I", System.StringComparison.Ordinal) && name.Length > 1) - name = name.Substring(1); - if (name.EndsWith("Contracts", System.StringComparison.Ordinal)) - name = name.Substring(0, name.Length - "Contracts".Length); - return name; - } } diff --git a/framework/SimpleModule.Generator/Emitters/Diagnostics/ContractAndDtoChecks.cs b/framework/SimpleModule.Generator/Emitters/Diagnostics/ContractAndDtoChecks.cs new file mode 100644 index 00000000..ed6ca6ec --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/Diagnostics/ContractAndDtoChecks.cs @@ -0,0 +1,231 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal static class ContractAndDtoChecks +{ + internal static void Run(SourceProductionContext context, DiscoveryData data) + { + // SM0012/SM0013: Contract interface size + foreach (var iface in data.ContractInterfaces) + { + if (iface.MethodCount > 20) + { + var shortName = ExtractShortName(iface.InterfaceName); + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.ContractInterfaceTooLargeError, + LocationHelper.ToLocation(iface.Location), + Strip(iface.InterfaceName), + iface.MethodCount, + shortName + ) + ); + } + else if (iface.MethodCount >= 15) + { + var shortName = ExtractShortName(iface.InterfaceName); + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.ContractInterfaceTooLargeWarning, + LocationHelper.ToLocation(iface.Location), + Strip(iface.InterfaceName), + iface.MethodCount, + shortName + ) + ); + } + } + + // SM0014: Missing contract interfaces in referenced contracts assemblies + var contractsWithInterfaces = new HashSet(); + foreach (var iface in data.ContractInterfaces) + contractsWithInterfaces.Add(iface.ContractsAssemblyName); + + // Deduplicate: only report once per (module, contracts assembly) pair + var reported = new HashSet(); + foreach (var dep in data.Dependencies) + { + var key = dep.ModuleName + "|" + dep.ContractsAssemblyName; + if (!contractsWithInterfaces.Contains(dep.ContractsAssemblyName) && reported.Add(key)) + { + // Find the module's location for this diagnostic + SourceLocationRecord? depModuleLoc = null; + foreach (var module in data.Modules) + { + if (module.ModuleName == dep.ModuleName) + { + depModuleLoc = module.Location; + break; + } + } + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.MissingContractInterfaces, + LocationHelper.ToLocation(depModuleLoc), + dep.ModuleName, + dep.ContractsAssemblyName + ) + ); + } + } + + // SM0025/SM0026/SM0028/SM0029: Contract implementation diagnostics + // Group all implementations by interface FQN + var implsByInterface = new Dictionary>(); + foreach (var impl in data.ContractImplementations) + { + if (!implsByInterface.TryGetValue(impl.InterfaceFqn, out var list)) + { + list = new List(); + implsByInterface[impl.InterfaceFqn] = list; + } + list.Add(impl); + } + + // SM0028: Non-public implementations + // SM0029: Abstract implementations + foreach (var impl in data.ContractImplementations) + { + if (!impl.IsPublic) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.ContractImplementationNotPublic, + LocationHelper.ToLocation(impl.Location), + Strip(impl.ImplementationFqn), + Strip(impl.InterfaceFqn) + ) + ); + } + + if (impl.IsAbstract) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.ContractImplementationIsAbstract, + LocationHelper.ToLocation(impl.Location), + Strip(impl.ImplementationFqn), + Strip(impl.InterfaceFqn) + ) + ); + } + } + + // SM0025: No implementation for a contract interface + foreach (var iface in data.ContractInterfaces) + { + if (!implsByInterface.ContainsKey(iface.InterfaceName)) + { + // Derive module name from contracts assembly name + var moduleName = iface.ContractsAssemblyName; + if (moduleName.EndsWith(".Contracts", System.StringComparison.Ordinal)) + moduleName = moduleName.Substring(0, moduleName.Length - ".Contracts".Length); + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.NoContractImplementation, + LocationHelper.ToLocation(iface.Location), + Strip(iface.InterfaceName), + moduleName + ) + ); + } + } + + // SM0026: Multiple valid implementations for the same interface + foreach (var kvp in implsByInterface) + { + var validImpls = new List(); + foreach (var impl in kvp.Value) + { + if (impl.IsPublic && !impl.IsAbstract) + validImpls.Add(impl); + } + + if (validImpls.Count > 1) + { + var names = new List(); + foreach (var impl in validImpls) + names.Add(Strip(impl.ImplementationFqn)); + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.MultipleContractImplementations, + LocationHelper.ToLocation(validImpls[1].Location), + Strip(kvp.Key), + validImpls[0].ModuleName, + string.Join(", ", names) + ) + ); + } + } + + // SM0035: DTO type in contracts with no public properties + // Exclude permission and feature classes — they only have const string fields, not properties + var permissionClassFqns = new HashSet(); + foreach (var perm in data.PermissionClasses) + permissionClassFqns.Add(perm.FullyQualifiedName); + foreach (var feat in data.FeatureClasses) + permissionClassFqns.Add(feat.FullyQualifiedName); + + foreach (var dto in data.DtoTypes) + { + if ( + dto.FullyQualifiedName.Contains(".Contracts.") + && dto.Properties.Length == 0 + && !permissionClassFqns.Contains(dto.FullyQualifiedName) + ) + { + // Extract assembly/namespace context from FQN + var fqn = Strip(dto.FullyQualifiedName); + var contractsIdx = fqn.IndexOf(".Contracts.", System.StringComparison.Ordinal); + var contractsAsm = + contractsIdx >= 0 ? fqn.Substring(0, contractsIdx + ".Contracts".Length) : fqn; + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.DtoTypeNoProperties, + Location.None, + fqn, + contractsAsm + ) + ); + } + } + + // SM0038: Infrastructure type (DbContext subclass) in Contracts assembly + foreach (var dto in data.DtoTypes) + { + if ( + dto.FullyQualifiedName.Contains(".Contracts.") + && dto.FullyQualifiedName.Contains("DbContext") + ) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.InfrastructureTypeInContracts, + Location.None, + Strip(dto.FullyQualifiedName) + ) + ); + } + } + } + + private static string Strip(string fqn) => TypeMappingHelpers.StripGlobalPrefix(fqn); + + private static string ExtractShortName(string interfaceName) + { + var name = Strip(interfaceName); + if (name.Contains(".")) + name = name.Substring(name.LastIndexOf('.') + 1); + if (name.StartsWith("I", System.StringComparison.Ordinal) && name.Length > 1) + name = name.Substring(1); + if (name.EndsWith("Contracts", System.StringComparison.Ordinal)) + name = name.Substring(0, name.Length - "Contracts".Length); + return name; + } +} From 6ea1e12bc088e03b1fbbe3040dbe73cd03e589e5 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 20:59:51 +0200 Subject: [PATCH 29/38] refactor(generator): extract PermissionFeatureChecks --- .../Emitters/DiagnosticEmitter.cs | 184 +---------------- .../Diagnostics/PermissionFeatureChecks.cs | 194 ++++++++++++++++++ 2 files changed, 195 insertions(+), 183 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/Diagnostics/PermissionFeatureChecks.cs diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index 527817bd..92ebfda6 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.IO; using System.Linq; using Microsoft.CodeAnalysis; @@ -15,103 +14,12 @@ public void Emit(SourceProductionContext context, DiscoveryData data) DbContextChecks.Run(context, data); DependencyChecks.Run(context, data); ContractAndDtoChecks.Run(context, data); + PermissionFeatureChecks.Run(context, data); // SM0004: DbContext with no DbSets — silently skipped. // Some DbContexts (e.g., OpenIddict) manage tables internally without public DbSet // properties. These are excluded from the unified HostDbContext but are not an error. - // SM0027/SM0031/SM0032/SM0033/SM0034: Permission diagnostics - // Track permission values for duplicate detection (value -> class FQN) - var permissionValueOwners = new Dictionary(); - - foreach (var perm in data.PermissionClasses) - { - var permCleanName = Strip(perm.FullyQualifiedName); - - // SM0032: Not sealed - if (!perm.IsSealed) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.PermissionClassNotSealed, - LocationHelper.ToLocation(perm.Location), - permCleanName - ) - ); - } - - foreach (var field in perm.Fields) - { - // SM0027: Field is not const string - if (!field.IsConstString) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.PermissionFieldNotConstString, - LocationHelper.ToLocation(field.Location), - permCleanName, - field.FieldName - ) - ); - continue; - } - - // SM0031: Value doesn't match {Module}.{Action} pattern (exactly one dot) - var dotCount = 0; - foreach (var ch in field.Value) - { - if (ch == '.') - dotCount++; - } - if (dotCount != 1) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.PermissionValueBadPattern, - LocationHelper.ToLocation(field.Location), - field.Value, - permCleanName - ) - ); - } - - // SM0034: Value prefix doesn't match module name - if (dotCount >= 1) - { - var prefix = field.Value.Substring(0, field.Value.IndexOf('.')); - if (!string.Equals(prefix, perm.ModuleName, System.StringComparison.Ordinal)) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.PermissionValueWrongPrefix, - LocationHelper.ToLocation(field.Location), - field.Value, - perm.ModuleName - ) - ); - } - } - - // SM0033: Duplicate permission value - if (permissionValueOwners.TryGetValue(field.Value, out var existingOwner)) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.DuplicatePermissionValue, - LocationHelper.ToLocation(field.Location), - field.Value, - existingOwner, - permCleanName - ) - ); - } - else - { - permissionValueOwners[field.Value] = permCleanName; - } - } - } - // SM0015: Duplicate view page name across modules var seenPages = new Dictionary(); foreach (var module in data.Modules) @@ -205,96 +113,6 @@ public void Emit(SourceProductionContext context, DiscoveryData data) } } - // SM0044: Multiple IModuleOptions for same module - var optionsByModule = ModuleOptionsRecord.GroupByModule(data.ModuleOptions); - - foreach (var kvp in optionsByModule) - { - if (kvp.Value.Count > 1) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.MultipleModuleOptions, - LocationHelper.ToLocation(kvp.Value[1].Location), - kvp.Key, - Strip(kvp.Value[0].FullyQualifiedName), - Strip(kvp.Value[1].FullyQualifiedName) - ) - ); - } - } - - // SM0045/SM0046/SM0047/SM0048: Feature flag diagnostics - var featureValueOwners = new Dictionary(); - - foreach (var feat in data.FeatureClasses) - { - var featCleanName = Strip(feat.FullyQualifiedName); - - // SM0045: Not sealed - if (!feat.IsSealed) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.FeatureClassNotSealed, - LocationHelper.ToLocation(feat.Location), - featCleanName - ) - ); - } - - foreach (var field in feat.Fields) - { - // SM0048: Not a const string - if (!field.IsConstString) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.FeatureFieldNotConstString, - LocationHelper.ToLocation(field.Location), - field.FieldName, - featCleanName - ) - ); - continue; - } - - // SM0046: Naming violation - if (!field.Value.Contains(".")) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.FeatureFieldNamingViolation, - LocationHelper.ToLocation(field.Location), - field.Value, - featCleanName - ) - ); - } - - // SM0047: Duplicate feature name - if (featureValueOwners.TryGetValue(field.Value, out var existingOwner)) - { - if (existingOwner != feat.FullyQualifiedName) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.DuplicateFeatureName, - LocationHelper.ToLocation(field.Location), - field.Value, - Strip(existingOwner), - featCleanName - ) - ); - } - } - else - { - featureValueOwners[field.Value] = feat.FullyQualifiedName; - } - } - } - // SM0049: Multiple endpoints (IViewEndpoint) in a single file var viewsByFile = new Dictionary>(); diff --git a/framework/SimpleModule.Generator/Emitters/Diagnostics/PermissionFeatureChecks.cs b/framework/SimpleModule.Generator/Emitters/Diagnostics/PermissionFeatureChecks.cs new file mode 100644 index 00000000..3a4ec2b6 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/Diagnostics/PermissionFeatureChecks.cs @@ -0,0 +1,194 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal static class PermissionFeatureChecks +{ + internal static void Run(SourceProductionContext context, DiscoveryData data) + { + // SM0027/SM0031/SM0032/SM0033/SM0034: Permission diagnostics + // Track permission values for duplicate detection (value -> class FQN) + var permissionValueOwners = new Dictionary(); + + foreach (var perm in data.PermissionClasses) + { + var permCleanName = Strip(perm.FullyQualifiedName); + + // SM0032: Not sealed + if (!perm.IsSealed) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.PermissionClassNotSealed, + LocationHelper.ToLocation(perm.Location), + permCleanName + ) + ); + } + + foreach (var field in perm.Fields) + { + // SM0027: Field is not const string + if (!field.IsConstString) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.PermissionFieldNotConstString, + LocationHelper.ToLocation(field.Location), + permCleanName, + field.FieldName + ) + ); + continue; + } + + // SM0031: Value doesn't match {Module}.{Action} pattern (exactly one dot) + var dotCount = 0; + foreach (var ch in field.Value) + { + if (ch == '.') + dotCount++; + } + if (dotCount != 1) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.PermissionValueBadPattern, + LocationHelper.ToLocation(field.Location), + field.Value, + permCleanName + ) + ); + } + + // SM0034: Value prefix doesn't match module name + if (dotCount >= 1) + { + var prefix = field.Value.Substring(0, field.Value.IndexOf('.')); + if (!string.Equals(prefix, perm.ModuleName, System.StringComparison.Ordinal)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.PermissionValueWrongPrefix, + LocationHelper.ToLocation(field.Location), + field.Value, + perm.ModuleName + ) + ); + } + } + + // SM0033: Duplicate permission value + if (permissionValueOwners.TryGetValue(field.Value, out var existingOwner)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.DuplicatePermissionValue, + LocationHelper.ToLocation(field.Location), + field.Value, + existingOwner, + permCleanName + ) + ); + } + else + { + permissionValueOwners[field.Value] = permCleanName; + } + } + } + + // SM0044: Multiple IModuleOptions for same module + var optionsByModule = ModuleOptionsRecord.GroupByModule(data.ModuleOptions); + + foreach (var kvp in optionsByModule) + { + if (kvp.Value.Count > 1) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.MultipleModuleOptions, + LocationHelper.ToLocation(kvp.Value[1].Location), + kvp.Key, + Strip(kvp.Value[0].FullyQualifiedName), + Strip(kvp.Value[1].FullyQualifiedName) + ) + ); + } + } + + // SM0045/SM0046/SM0047/SM0048: Feature flag diagnostics + var featureValueOwners = new Dictionary(); + + foreach (var feat in data.FeatureClasses) + { + var featCleanName = Strip(feat.FullyQualifiedName); + + // SM0045: Not sealed + if (!feat.IsSealed) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.FeatureClassNotSealed, + LocationHelper.ToLocation(feat.Location), + featCleanName + ) + ); + } + + foreach (var field in feat.Fields) + { + // SM0048: Not a const string + if (!field.IsConstString) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.FeatureFieldNotConstString, + LocationHelper.ToLocation(field.Location), + field.FieldName, + featCleanName + ) + ); + continue; + } + + // SM0046: Naming violation + if (!field.Value.Contains(".")) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.FeatureFieldNamingViolation, + LocationHelper.ToLocation(field.Location), + field.Value, + featCleanName + ) + ); + } + + // SM0047: Duplicate feature name + if (featureValueOwners.TryGetValue(field.Value, out var existingOwner)) + { + if (existingOwner != feat.FullyQualifiedName) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.DuplicateFeatureName, + LocationHelper.ToLocation(field.Location), + field.Value, + Strip(existingOwner), + featCleanName + ) + ); + } + } + else + { + featureValueOwners[field.Value] = feat.FullyQualifiedName; + } + } + } + } + + private static string Strip(string fqn) => TypeMappingHelpers.StripGlobalPrefix(fqn); +} From 54c241b071c56985d8ac3afc353fe99b013bf829 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 21:02:31 +0200 Subject: [PATCH 30/38] refactor(generator): extract EndpointChecks, trim DiagnosticEmitter to orchestrator --- .../Emitters/DiagnosticEmitter.cs | 247 +---------------- .../Emitters/Diagnostics/EndpointChecks.cs | 251 ++++++++++++++++++ 2 files changed, 252 insertions(+), 246 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/Diagnostics/EndpointChecks.cs diff --git a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs index 92ebfda6..6a2669f9 100644 --- a/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/DiagnosticEmitter.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using Microsoft.CodeAnalysis; namespace SimpleModule.Generator; @@ -15,247 +11,6 @@ public void Emit(SourceProductionContext context, DiscoveryData data) DependencyChecks.Run(context, data); ContractAndDtoChecks.Run(context, data); PermissionFeatureChecks.Run(context, data); - - // SM0004: DbContext with no DbSets — silently skipped. - // Some DbContexts (e.g., OpenIddict) manage tables internally without public DbSet - // properties. These are excluded from the unified HostDbContext but are not an error. - - // SM0015: Duplicate view page name across modules - var seenPages = new Dictionary(); - foreach (var module in data.Modules) - { - foreach (var view in module.Views) - { - if (seenPages.TryGetValue(view.Page, out var existing)) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.DuplicateViewPageName, - LocationHelper.ToLocation(view.Location), - view.Page, - Strip(existing.EndpointFqn), - existing.ModuleName, - Strip(view.FullyQualifiedName), - module.ModuleName - ) - ); - } - else - { - seenPages[view.Page] = (view.FullyQualifiedName, module.ModuleName); - } - } - } - - // SM0041: View page prefix must match module name - foreach (var module in data.Modules) - { - if (string.IsNullOrEmpty(module.ModuleName)) - continue; - - var expectedPrefix = module.ModuleName + "/"; - foreach (var view in module.Views) - { - if (!view.Page.StartsWith(expectedPrefix, System.StringComparison.Ordinal)) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.ViewPagePrefixMismatch, - LocationHelper.ToLocation(view.Location), - Strip(view.FullyQualifiedName), - module.ModuleName, - view.Page - ) - ); - } - } - } - - // SM0042: Module with views but no ViewPrefix - foreach (var module in data.Modules) - { - if (module.Views.Length > 0 && string.IsNullOrEmpty(module.ViewPrefix)) - { -#pragma warning disable CA1308 // Route prefixes are conventionally lowercase - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.ViewEndpointWithoutViewPrefix, - LocationHelper.ToLocation(module.Location), - module.ModuleName, - module.Views.Length, - module.ModuleName.ToLowerInvariant() - ) - ); -#pragma warning restore CA1308 - } - } - - // SM0039: Interceptor depends on contract whose implementation takes a DbContext - foreach (var interceptor in data.Interceptors) - { - foreach (var paramFqn in interceptor.ConstructorParamTypeFqns) - { - foreach (var impl in data.ContractImplementations) - { - if (impl.InterfaceFqn == paramFqn && impl.DependsOnDbContext) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.InterceptorDependsOnDbContext, - LocationHelper.ToLocation(interceptor.Location), - Strip(interceptor.FullyQualifiedName), - interceptor.ModuleName, - Strip(paramFqn) - ) - ); - } - } - } - } - - // SM0049: Multiple endpoints (IViewEndpoint) in a single file - var viewsByFile = new Dictionary>(); - - foreach (var module in data.Modules) - { - foreach (var view in module.Views) - { - if (view.Location is { } loc && !string.IsNullOrEmpty(loc.FilePath)) - { - if (!viewsByFile.TryGetValue(loc.FilePath, out var list)) - { - list = new List<(string Name, SourceLocationRecord Loc)>(); - viewsByFile[loc.FilePath] = list; - } - - list.Add((Strip(view.FullyQualifiedName), loc)); - } - } - } - - foreach (var kvp in viewsByFile) - { - if (kvp.Value.Count > 1) - { - var names = string.Join(", ", kvp.Value.Select(e => e.Name)); - var fileName = Path.GetFileName(kvp.Key); - - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.MultipleEndpointsPerFile, - LocationHelper.ToLocation(kvp.Value[1].Loc), - fileName, - names - ) - ); - } - } - - // SM0052: Module assembly name must follow SimpleModule.{ModuleName} convention - // SM0053: Module must have matching Contracts assembly - // These checks only apply when the host project itself is a SimpleModule.* project. - // User projects (e.g. TestApp.Host) use their own naming conventions. - var hostIsFramework = - data.HostAssemblyName?.StartsWith("SimpleModule.", System.StringComparison.Ordinal) - == true; - - if (hostIsFramework) - { - var contractsSet = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var name in data.ContractsAssemblyNames) - contractsSet.Add(name); - - foreach (var module in data.Modules) - { - if (string.IsNullOrEmpty(module.ModuleName)) - continue; - - // SM0052: Assembly naming convention - // Accepted patterns: SimpleModule.{ModuleName} or SimpleModule.{ModuleName}.Module - // The .Module suffix is allowed when a framework assembly with the same base name exists. - var expectedAssemblyName = "SimpleModule." + module.ModuleName; - var expectedModuleSuffix = expectedAssemblyName + ".Module"; - if ( - !string.IsNullOrEmpty(module.AssemblyName) - && !string.Equals( - module.AssemblyName, - expectedAssemblyName, - System.StringComparison.Ordinal - ) - && !string.Equals( - module.AssemblyName, - expectedModuleSuffix, - System.StringComparison.Ordinal - ) - && !string.Equals( - module.AssemblyName, - data.HostAssemblyName, - System.StringComparison.Ordinal - ) - ) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.ModuleAssemblyNamingViolation, - LocationHelper.ToLocation(module.Location), - module.ModuleName, - module.AssemblyName - ) - ); - } - - // SM0053: Missing contracts assembly - var expectedContractsName = "SimpleModule." + module.ModuleName + ".Contracts"; - if ( - !contractsSet.Contains(expectedContractsName) - && !string.Equals( - module.AssemblyName, - data.HostAssemblyName, - System.StringComparison.Ordinal - ) - ) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.MissingContractsAssembly, - LocationHelper.ToLocation(module.Location), - module.ModuleName, - module.AssemblyName - ) - ); - } - - // SM0054: Endpoint missing Route const - foreach (var endpoint in module.Endpoints) - { - if (string.IsNullOrEmpty(endpoint.RouteTemplate)) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.MissingEndpointRouteConst, - Location.None, - Strip(endpoint.FullyQualifiedName) - ) - ); - } - } - - foreach (var view in module.Views) - { - if (string.IsNullOrEmpty(view.RouteTemplate)) - { - context.ReportDiagnostic( - Diagnostic.Create( - DiagnosticDescriptors.MissingEndpointRouteConst, - LocationHelper.ToLocation(view.Location), - Strip(view.FullyQualifiedName) - ) - ); - } - } - } - } + EndpointChecks.Run(context, data); } - - private static string Strip(string fqn) => TypeMappingHelpers.StripGlobalPrefix(fqn); } diff --git a/framework/SimpleModule.Generator/Emitters/Diagnostics/EndpointChecks.cs b/framework/SimpleModule.Generator/Emitters/Diagnostics/EndpointChecks.cs new file mode 100644 index 00000000..0a8a0119 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/Diagnostics/EndpointChecks.cs @@ -0,0 +1,251 @@ +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) + { + // SM0015: Duplicate view page name across modules + var seenPages = new Dictionary(); + foreach (var module in data.Modules) + { + foreach (var view in module.Views) + { + if (seenPages.TryGetValue(view.Page, out var existing)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.DuplicateViewPageName, + LocationHelper.ToLocation(view.Location), + view.Page, + Strip(existing.EndpointFqn), + existing.ModuleName, + Strip(view.FullyQualifiedName), + module.ModuleName + ) + ); + } + else + { + seenPages[view.Page] = (view.FullyQualifiedName, module.ModuleName); + } + } + } + + // SM0041: View page prefix must match module name + foreach (var module in data.Modules) + { + if (string.IsNullOrEmpty(module.ModuleName)) + continue; + + var expectedPrefix = module.ModuleName + "/"; + foreach (var view in module.Views) + { + if (!view.Page.StartsWith(expectedPrefix, System.StringComparison.Ordinal)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.ViewPagePrefixMismatch, + LocationHelper.ToLocation(view.Location), + Strip(view.FullyQualifiedName), + module.ModuleName, + view.Page + ) + ); + } + } + } + + // SM0042: Module with views but no ViewPrefix + foreach (var module in data.Modules) + { + if (module.Views.Length > 0 && string.IsNullOrEmpty(module.ViewPrefix)) + { +#pragma warning disable CA1308 // Route prefixes are conventionally lowercase + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.ViewEndpointWithoutViewPrefix, + LocationHelper.ToLocation(module.Location), + module.ModuleName, + module.Views.Length, + module.ModuleName.ToLowerInvariant() + ) + ); +#pragma warning restore CA1308 + } + } + + // SM0039: Interceptor depends on contract whose implementation takes a DbContext + foreach (var interceptor in data.Interceptors) + { + foreach (var paramFqn in interceptor.ConstructorParamTypeFqns) + { + foreach (var impl in data.ContractImplementations) + { + if (impl.InterfaceFqn == paramFqn && impl.DependsOnDbContext) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.InterceptorDependsOnDbContext, + LocationHelper.ToLocation(interceptor.Location), + Strip(interceptor.FullyQualifiedName), + interceptor.ModuleName, + Strip(paramFqn) + ) + ); + } + } + } + } + + // SM0049: Multiple endpoints (IViewEndpoint) in a single file + var viewsByFile = new Dictionary>(); + + foreach (var module in data.Modules) + { + foreach (var view in module.Views) + { + if (view.Location is { } loc && !string.IsNullOrEmpty(loc.FilePath)) + { + if (!viewsByFile.TryGetValue(loc.FilePath, out var list)) + { + list = new List<(string Name, SourceLocationRecord Loc)>(); + viewsByFile[loc.FilePath] = list; + } + + list.Add((Strip(view.FullyQualifiedName), loc)); + } + } + } + + foreach (var kvp in viewsByFile) + { + if (kvp.Value.Count > 1) + { + var names = string.Join(", ", kvp.Value.Select(e => e.Name)); + var fileName = Path.GetFileName(kvp.Key); + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.MultipleEndpointsPerFile, + LocationHelper.ToLocation(kvp.Value[1].Loc), + fileName, + names + ) + ); + } + } + + // SM0052: Module assembly name must follow SimpleModule.{ModuleName} convention + // SM0053: Module must have matching Contracts assembly + // These checks only apply when the host project itself is a SimpleModule.* project. + // User projects (e.g. TestApp.Host) use their own naming conventions. + var hostIsFramework = + data.HostAssemblyName?.StartsWith("SimpleModule.", System.StringComparison.Ordinal) + == true; + + if (hostIsFramework) + { + var contractsSet = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var name in data.ContractsAssemblyNames) + contractsSet.Add(name); + + foreach (var module in data.Modules) + { + if (string.IsNullOrEmpty(module.ModuleName)) + continue; + + // SM0052: Assembly naming convention + // Accepted patterns: SimpleModule.{ModuleName} or SimpleModule.{ModuleName}.Module + // The .Module suffix is allowed when a framework assembly with the same base name exists. + var expectedAssemblyName = "SimpleModule." + module.ModuleName; + var expectedModuleSuffix = expectedAssemblyName + ".Module"; + if ( + !string.IsNullOrEmpty(module.AssemblyName) + && !string.Equals( + module.AssemblyName, + expectedAssemblyName, + System.StringComparison.Ordinal + ) + && !string.Equals( + module.AssemblyName, + expectedModuleSuffix, + System.StringComparison.Ordinal + ) + && !string.Equals( + module.AssemblyName, + data.HostAssemblyName, + System.StringComparison.Ordinal + ) + ) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.ModuleAssemblyNamingViolation, + LocationHelper.ToLocation(module.Location), + module.ModuleName, + module.AssemblyName + ) + ); + } + + // SM0053: Missing contracts assembly + var expectedContractsName = "SimpleModule." + module.ModuleName + ".Contracts"; + if ( + !contractsSet.Contains(expectedContractsName) + && !string.Equals( + module.AssemblyName, + data.HostAssemblyName, + System.StringComparison.Ordinal + ) + ) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.MissingContractsAssembly, + LocationHelper.ToLocation(module.Location), + module.ModuleName, + module.AssemblyName + ) + ); + } + + // SM0054: Endpoint missing Route const + foreach (var endpoint in module.Endpoints) + { + if (string.IsNullOrEmpty(endpoint.RouteTemplate)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.MissingEndpointRouteConst, + Location.None, + Strip(endpoint.FullyQualifiedName) + ) + ); + } + } + + foreach (var view in module.Views) + { + if (string.IsNullOrEmpty(view.RouteTemplate)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.MissingEndpointRouteConst, + LocationHelper.ToLocation(view.Location), + Strip(view.FullyQualifiedName) + ) + ); + } + } + } + } + } + + private static string Strip(string fqn) => TypeMappingHelpers.StripGlobalPrefix(fqn); +} From 87b44458b28aeeaddd719f21dca5b886038388fc Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 21:05:55 +0200 Subject: [PATCH 31/38] perf(generator): single-pass reference classification, eliminate 2 reference re-iterations --- .../Discovery/Finders/ContractFinder.cs | 18 ++++------ .../Discovery/Finders/DtoFinder.cs | 19 +++-------- .../Discovery/SymbolDiscovery.cs | 33 ++++++++++++++----- 3 files changed, 35 insertions(+), 35 deletions(-) diff --git a/framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs index 16635b62..2e6f15f1 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/ContractFinder.cs @@ -118,27 +118,21 @@ internal static int GetContractLifetime(INamedTypeSymbol typeSymbol) } /// - /// Finds every *.Contracts assembly referenced by the compilation and maps it - /// to the module it belongs to. Populates - /// (name → module name) and - /// (name → IAssemblySymbol) for downstream scans. + /// 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( - Compilation compilation, + IReadOnlyList contractsAssemblies, Dictionary moduleAssemblyMap, Dictionary contractsAssemblyMap, Dictionary contractsAssemblySymbols ) { - foreach (var reference in compilation.References) + foreach (var asm in contractsAssemblies) { - 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 diff --git a/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs index b276eee7..fcc48196 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs @@ -208,7 +208,8 @@ and var baseType /// with [Dto]. No-op when the DtoAttribute symbol isn't resolvable. /// internal static void DiscoverAttributedDtos( - Compilation compilation, + IReadOnlyList refAssemblies, + INamespaceSymbol hostGlobalNamespace, CoreSymbols symbols, List dtoTypes, CancellationToken cancellationToken @@ -217,16 +218,9 @@ CancellationToken cancellationToken if (symbols.DtoAttribute is null) return; - foreach (var reference in compilation.References) + foreach (var assemblySymbol in refAssemblies) { cancellationToken.ThrowIfCancellationRequested(); - - if ( - compilation.GetAssemblyOrModuleSymbol(reference) - is not IAssemblySymbol assemblySymbol - ) - continue; - FindDtoTypes( assemblySymbol.GlobalNamespace, symbols.DtoAttribute, @@ -235,11 +229,6 @@ is not IAssemblySymbol assemblySymbol ); } - FindDtoTypes( - compilation.Assembly.GlobalNamespace, - symbols.DtoAttribute, - dtoTypes, - cancellationToken - ); + FindDtoTypes(hostGlobalNamespace, symbols.DtoAttribute, dtoTypes, cancellationToken); } } diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index e57726d2..74af2108 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -19,18 +19,29 @@ CancellationToken cancellationToken return DiscoveryData.Empty; var s = symbols.Value; - var modules = new List(); - + // 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(); - if ( - compilation.GetAssemblyOrModuleSymbol(reference) - is not IAssemblySymbol assemblySymbol - ) + if (compilation.GetAssemblyOrModuleSymbol(reference) is not IAssemblySymbol asm) continue; + refAssemblies.Add(asm); + if (asm.Name.EndsWith(".Contracts", StringComparison.OrdinalIgnoreCase)) + contractsAssemblies.Add(asm); + } + + var modules = new List(); + + foreach (var assemblySymbol in refAssemblies) + { + cancellationToken.ThrowIfCancellationRequested(); + ModuleFinder.FindModuleTypes( assemblySymbol.GlobalNamespace, s, @@ -74,7 +85,13 @@ is not IAssemblySymbol assemblySymbol ); var dtoTypes = new List(); - DtoFinder.DiscoverAttributedDtos(compilation, s, dtoTypes, cancellationToken); + DtoFinder.DiscoverAttributedDtos( + refAssemblies, + compilation.Assembly.GlobalNamespace, + s, + dtoTypes, + cancellationToken + ); // --- Dependency inference --- cancellationToken.ThrowIfCancellationRequested(); @@ -97,7 +114,7 @@ is not IAssemblySymbol assemblySymbol StringComparer.OrdinalIgnoreCase ); ContractFinder.BuildContractsAssemblyMap( - compilation, + contractsAssemblies, moduleAssemblyMap, contractsAssemblyMap, contractsAssemblySymbols From aa7f7f2ab6da2fd81e774fe1875f7ddaef87a9b2 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 21:07:34 +0200 Subject: [PATCH 32/38] perf(generator): use modules-by-name dictionary for endpoint/view attribution --- .../Discovery/Finders/EndpointFinder.cs | 5 +++-- .../Discovery/SymbolDiscovery.cs | 11 ++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs index b5f23106..b98c30dc 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs @@ -164,6 +164,7 @@ private static (string route, string method) ReadRouteConstFields(INamedTypeSymb internal static void Discover( List modules, Dictionary moduleSymbols, + Dictionary modulesByName, CoreSymbols symbols, CancellationToken cancellationToken ) @@ -197,7 +198,7 @@ CancellationToken cancellationToken { var epFqn = TypeMappingHelpers.StripGlobalPrefix(ep.FullyQualifiedName); var ownerName = SymbolHelpers.FindClosestModuleName(epFqn, modules); - var owner = modules.Find(m => m.ModuleName == ownerName); + modulesByName.TryGetValue(ownerName, out var owner); if (owner is not null) owner.Endpoints.Add(ep); } @@ -217,7 +218,7 @@ CancellationToken cancellationToken { var vFqn = TypeMappingHelpers.StripGlobalPrefix(v.FullyQualifiedName); var ownerName = SymbolHelpers.FindClosestModuleName(vFqn, modules); - var owner = modules.Find(m => m.ModuleName == ownerName); + modulesByName.TryGetValue(ownerName, out var owner); if (owner is not null) { // Derive page name from namespace segments between module NS and class name. diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 74af2108..38d462f5 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -70,8 +70,17 @@ CancellationToken cancellationToken moduleSymbols[module.FullyQualifiedName] = typeSymbol; } + // 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) + { + if (!modulesByName.ContainsKey(module.ModuleName)) + modulesByName[module.ModuleName] = module; + } + // Discover IEndpoint and IViewEndpoint implementors per module assembly - EndpointFinder.Discover(modules, moduleSymbols, s, cancellationToken); + EndpointFinder.Discover(modules, moduleSymbols, modulesByName, s, cancellationToken); // Discover DbContext subclasses and IEntityTypeConfiguration per module assembly var dbContexts = new List(); From 35fb65876e259dc488d0b98195a3ba1378747e12 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 21:09:09 +0200 Subject: [PATCH 33/38] perf(generator): lift moduleNsByName build out of per-module loop --- .../Discovery/Finders/EndpointFinder.cs | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs index b98c30dc..52ab4041 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs @@ -172,6 +172,18 @@ 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(); @@ -203,17 +215,6 @@ CancellationToken cancellationToken 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); From 52ee95150c6cb83777fc330104fff8859f7bafe0 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 21:11:47 +0200 Subject: [PATCH 34/38] perf(generator): use pre-sorted namespace index for FindClosestModuleName --- .../Discovery/Finders/DbContextFinder.cs | 5 +- .../Discovery/Finders/EndpointFinder.cs | 5 +- .../Discovery/SymbolDiscovery.cs | 13 ++++- .../Discovery/SymbolHelpers.cs | 54 +++++++++++++------ 4 files changed, 56 insertions(+), 21 deletions(-) diff --git a/framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs index 61af9957..75ef7e0f 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/DbContextFinder.cs @@ -185,6 +185,7 @@ member is INamedTypeSymbol typeSymbol internal static void Discover( List modules, Dictionary moduleSymbols, + SymbolHelpers.ModuleNamespaceIndex moduleNsIndex, List dbContexts, List entityConfigs, CancellationToken cancellationToken @@ -217,14 +218,14 @@ CancellationToken cancellationToken foreach (var ctx in rawDbContexts) { var ctxNs = TypeMappingHelpers.StripGlobalPrefix(ctx.FullyQualifiedName); - ctx.ModuleName = SymbolHelpers.FindClosestModuleName(ctxNs, modules); + ctx.ModuleName = SymbolHelpers.FindClosestModuleNameFast(ctxNs, moduleNsIndex); dbContexts.Add(ctx); } foreach (var cfg in rawEntityConfigs) { var cfgNs = TypeMappingHelpers.StripGlobalPrefix(cfg.ConfigFqn); - cfg.ModuleName = SymbolHelpers.FindClosestModuleName(cfgNs, modules); + cfg.ModuleName = SymbolHelpers.FindClosestModuleNameFast(cfgNs, moduleNsIndex); entityConfigs.Add(cfg); } } diff --git a/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs index 52ab4041..96f1f9d2 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/EndpointFinder.cs @@ -165,6 +165,7 @@ internal static void Discover( List modules, Dictionary moduleSymbols, Dictionary modulesByName, + SymbolHelpers.ModuleNamespaceIndex moduleNsIndex, CoreSymbols symbols, CancellationToken cancellationToken ) @@ -209,7 +210,7 @@ CancellationToken cancellationToken foreach (var ep in rawEndpoints) { var epFqn = TypeMappingHelpers.StripGlobalPrefix(ep.FullyQualifiedName); - var ownerName = SymbolHelpers.FindClosestModuleName(epFqn, modules); + var ownerName = SymbolHelpers.FindClosestModuleNameFast(epFqn, moduleNsIndex); modulesByName.TryGetValue(ownerName, out var owner); if (owner is not null) owner.Endpoints.Add(ep); @@ -218,7 +219,7 @@ CancellationToken cancellationToken foreach (var v in rawViews) { var vFqn = TypeMappingHelpers.StripGlobalPrefix(v.FullyQualifiedName); - var ownerName = SymbolHelpers.FindClosestModuleName(vFqn, modules); + var ownerName = SymbolHelpers.FindClosestModuleNameFast(vFqn, moduleNsIndex); modulesByName.TryGetValue(ownerName, out var owner); if (owner is not null) { diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 38d462f5..337b79af 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -79,8 +79,18 @@ CancellationToken cancellationToken modulesByName[module.ModuleName] = module; } + // Pre-built namespace index for O(1)-amortised module attribution across all finders. + var moduleNsIndex = SymbolHelpers.BuildModuleNamespaceIndex(modules); + // Discover IEndpoint and IViewEndpoint implementors per module assembly - EndpointFinder.Discover(modules, moduleSymbols, modulesByName, s, cancellationToken); + EndpointFinder.Discover( + modules, + moduleSymbols, + modulesByName, + moduleNsIndex, + s, + cancellationToken + ); // Discover DbContext subclasses and IEntityTypeConfiguration per module assembly var dbContexts = new List(); @@ -88,6 +98,7 @@ CancellationToken cancellationToken DbContextFinder.Discover( modules, moduleSymbols, + moduleNsIndex, dbContexts, entityConfigs, cancellationToken diff --git a/framework/SimpleModule.Generator/Discovery/SymbolHelpers.cs b/framework/SimpleModule.Generator/Discovery/SymbolHelpers.cs index e2b1f945..34cbb251 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolHelpers.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolHelpers.cs @@ -89,27 +89,49 @@ Action action } } - internal static string FindClosestModuleName(string typeFqn, List modules) + /// + /// 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 { - // Match by longest shared namespace prefix between the type and each module class. - var bestMatch = ""; - var bestLength = -1; - foreach (var module in modules) + internal readonly (string Namespace, string ModuleName)[] Entries; + internal readonly string FirstModuleName; + + internal ModuleNamespaceIndex( + (string Namespace, string ModuleName)[] entries, + string firstModuleName + ) { - var moduleFqn = TypeMappingHelpers.StripGlobalPrefix(module.FullyQualifiedName); - var moduleNs = TypeMappingHelpers.ExtractNamespace(moduleFqn); + Entries = entries; + FirstModuleName = firstModuleName; + } + } - if ( - typeFqn.StartsWith(moduleNs, StringComparison.Ordinal) - && moduleNs.Length > bestLength - ) - { - bestLength = moduleNs.Length; - bestMatch = module.ModuleName; - } + 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); } - return bestMatch.Length > 0 ? bestMatch : modules[0].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; } /// From 057b0b549ba6de79d22920737e68ff5fa8dd439e Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 21:13:37 +0200 Subject: [PATCH 35/38] perf(generator): skip System/Microsoft/Vogen trees in convention DTO scan --- .../SimpleModule.Generator/Discovery/Finders/DtoFinder.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs index fcc48196..d03e6fb6 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/DtoFinder.cs @@ -94,6 +94,12 @@ CancellationToken cancellationToken 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, From 42d3600ad5fbc43fa7e70891e9f5602096829c98 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 21:17:21 +0200 Subject: [PATCH 36/38] test(generator): lock in incremental caching behaviour after refactor --- .../IncrementalCachingTests.cs | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 tests/SimpleModule.Generator.Tests/IncrementalCachingTests.cs diff --git a/tests/SimpleModule.Generator.Tests/IncrementalCachingTests.cs b/tests/SimpleModule.Generator.Tests/IncrementalCachingTests.cs new file mode 100644 index 00000000..7c73e5db --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/IncrementalCachingTests.cs @@ -0,0 +1,63 @@ +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 (DiscoveryData) 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" + ); + } +} From 4df1864b0ffa0391330ef1e84e999edc1a7cf0e5 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 21:19:02 +0200 Subject: [PATCH 37/38] test(generator): lock in diagnostic catalog against baseline Adds a reflection-based test that snapshots all 38 DiagnosticDescriptor fields from DiagnosticDescriptors (ID, severity, category) and asserts they match the post-refactor baseline. Any accidental add, remove, or change to a descriptor will fail the test. --- .../DiagnosticCatalogTests.cs | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 tests/SimpleModule.Generator.Tests/DiagnosticCatalogTests.cs diff --git a/tests/SimpleModule.Generator.Tests/DiagnosticCatalogTests.cs b/tests/SimpleModule.Generator.Tests/DiagnosticCatalogTests.cs new file mode 100644 index 00000000..a36d8091 --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/DiagnosticCatalogTests.cs @@ -0,0 +1,214 @@ +using System.Collections.Generic; +using System.Reflection; +using FluentAssertions; +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator.Tests; + +public class DiagnosticCatalogTests +{ + // Baseline captured from DiagnosticDescriptors.cs on the post-refactor commit. + // If you intentionally add/remove a diagnostic, update this table in the same commit. + // The test uses this to catch accidental drift of SM00xx IDs, severities, or categories. + private static readonly Dictionary< + string, + (string Id, DiagnosticSeverity Severity, string Category) + > 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.Error, + "SimpleModule.Generator" + ), + ["DuplicateViewPageName"] = ("SM0015", DiagnosticSeverity.Error, "SimpleModule.Generator"), + ["NoContractImplementation"] = ( + "SM0025", + DiagnosticSeverity.Error, + "SimpleModule.Generator" + ), + ["MultipleContractImplementations"] = ( + "SM0026", + DiagnosticSeverity.Error, + "SimpleModule.Generator" + ), + ["PermissionFieldNotConstString"] = ( + "SM0027", + DiagnosticSeverity.Error, + "SimpleModule.Generator" + ), + ["ContractImplementationNotPublic"] = ( + "SM0028", + DiagnosticSeverity.Error, + "SimpleModule.Generator" + ), + ["ContractImplementationIsAbstract"] = ( + "SM0029", + DiagnosticSeverity.Error, + "SimpleModule.Generator" + ), + ["PermissionValueBadPattern"] = ( + "SM0031", + DiagnosticSeverity.Warning, + "SimpleModule.Generator" + ), + ["PermissionClassNotSealed"] = ( + "SM0032", + DiagnosticSeverity.Error, + "SimpleModule.Generator" + ), + ["DuplicatePermissionValue"] = ( + "SM0033", + DiagnosticSeverity.Error, + "SimpleModule.Generator" + ), + ["PermissionValueWrongPrefix"] = ( + "SM0034", + DiagnosticSeverity.Warning, + "SimpleModule.Generator" + ), + ["DtoTypeNoProperties"] = ("SM0035", DiagnosticSeverity.Warning, "SimpleModule.Generator"), + ["InfrastructureTypeInContracts"] = ( + "SM0038", + DiagnosticSeverity.Warning, + "SimpleModule.Generator" + ), + ["InterceptorDependsOnDbContext"] = ( + "SM0039", + DiagnosticSeverity.Warning, + "SimpleModule.Generator" + ), + ["DuplicateModuleName"] = ("SM0040", DiagnosticSeverity.Error, "SimpleModule.Generator"), + ["ViewPagePrefixMismatch"] = ( + "SM0041", + DiagnosticSeverity.Warning, + "SimpleModule.Generator" + ), + ["ViewEndpointWithoutViewPrefix"] = ( + "SM0042", + DiagnosticSeverity.Error, + "SimpleModule.Generator" + ), + ["EmptyModuleWarning"] = ("SM0043", DiagnosticSeverity.Warning, "SimpleModule.Generator"), + ["MultipleModuleOptions"] = ( + "SM0044", + DiagnosticSeverity.Warning, + "SimpleModule.Generator" + ), + ["FeatureClassNotSealed"] = ("SM0045", DiagnosticSeverity.Error, "SimpleModule.Generator"), + ["FeatureFieldNamingViolation"] = ( + "SM0046", + DiagnosticSeverity.Warning, + "SimpleModule.Generator" + ), + ["DuplicateFeatureName"] = ("SM0047", DiagnosticSeverity.Error, "SimpleModule.Generator"), + ["FeatureFieldNotConstString"] = ( + "SM0048", + DiagnosticSeverity.Error, + "SimpleModule.Generator" + ), + ["MultipleEndpointsPerFile"] = ( + "SM0049", + DiagnosticSeverity.Error, + "SimpleModule.Generator" + ), + ["ModuleAssemblyNamingViolation"] = ( + "SM0052", + DiagnosticSeverity.Error, + "SimpleModule.Generator" + ), + ["MissingContractsAssembly"] = ( + "SM0053", + DiagnosticSeverity.Error, + "SimpleModule.Generator" + ), + ["MissingEndpointRouteConst"] = ( + "SM0054", + DiagnosticSeverity.Info, + "SimpleModule.Generator" + ), + ["EntityNotInContractsAssembly"] = ( + "SM0055", + DiagnosticSeverity.Error, + "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"); + } + } +} From 686318de14c12c55c55fc2b1dcfcecacbb037ba0 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 15 Apr 2026 21:23:15 +0200 Subject: [PATCH 38/38] refactor(generator): split DiagnosticDescriptors into 6 partial files by concern --- .../DiagnosticDescriptors.ContractAndDto.cs | 87 +++++ .../DiagnosticDescriptors.DbContext.cs | 60 +++ .../DiagnosticDescriptors.Dependency.cs | 24 ++ .../DiagnosticDescriptors.Endpoint.cs | 78 ++++ .../DiagnosticDescriptors.Module.cs | 33 ++ ...DiagnosticDescriptors.PermissionFeature.cs | 96 +++++ .../Diagnostics/DiagnosticDescriptors.cs | 348 ------------------ 7 files changed, 378 insertions(+), 348 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.ContractAndDto.cs create mode 100644 framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.DbContext.cs create mode 100644 framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.Dependency.cs create mode 100644 framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.Endpoint.cs create mode 100644 framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.Module.cs create mode 100644 framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.PermissionFeature.cs delete mode 100644 framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.cs diff --git a/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.ContractAndDto.cs b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.ContractAndDto.cs new file mode 100644 index 00000000..51463453 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.ContractAndDto.cs @@ -0,0 +1,87 @@ +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal static partial class DiagnosticDescriptors +{ + internal static readonly DiagnosticDescriptor ContractInterfaceTooLargeWarning = new( + id: "SM0012", + title: "Contract interface has too many methods", + messageFormat: "Contract interface '{0}' has {1} methods, which exceeds the recommended maximum of 15. Large contract interfaces force consuming modules to depend on methods they don't use. Consider splitting into focused interfaces (e.g., I{2}Queries, I{2}Commands). Your module class can implement all of them. Warning threshold: 15 methods, error threshold: 20 methods. Learn more: https://docs.simplemodule.dev/contract-design.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor ContractInterfaceTooLargeError = new( + id: "SM0013", + title: "Contract interface must be split", + messageFormat: "Contract interface '{0}' has {1} methods and must be split before the project will compile. Interfaces with more than 20 methods are not allowed. Split into focused interfaces (e.g., I{2}Queries, I{2}Commands). Your module class can implement all of them. Warning threshold: 15 methods, error threshold: 20 methods. Learn more: https://docs.simplemodule.dev/contract-design.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor MissingContractInterfaces = new( + id: "SM0014", + title: "Referenced contracts assembly has no public interfaces", + messageFormat: "Module '{0}' references '{1}' but no contract interfaces were found in that assembly. Likely causes: (1) Incompatible package version \u2014 check with 'dotnet list package --include-transitive'. (2) The Contracts project is empty or not yet built. (3) The package is corrupted \u2014 try 'dotnet nuget locals all --clear' then 'dotnet restore'. Verify that the version of {1} you're using exports the interfaces your code depends on. Learn more: https://docs.simplemodule.dev/package-compatibility.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor NoContractImplementation = new( + id: "SM0025", + title: "No implementation found for contract interface", + messageFormat: "No implementation of '{0}' found in module '{1}'. Add a public class implementing this interface.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor MultipleContractImplementations = new( + id: "SM0026", + title: "Multiple implementations of contract interface", + messageFormat: "Multiple implementations of '{0}' found in module '{1}': {2}. Only one implementation per contract interface is allowed.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor ContractImplementationNotPublic = new( + id: "SM0028", + title: "Contract implementation is not public", + messageFormat: "Implementation '{0}' of '{1}' must be public. The DI container cannot access internal types across assemblies.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor ContractImplementationIsAbstract = new( + id: "SM0029", + title: "Contract implementation is abstract", + messageFormat: "'{0}' implements '{1}' but is abstract. Provide a concrete implementation.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor DtoTypeNoProperties = new( + id: "SM0035", + title: "DTO type in contracts has no public properties", + messageFormat: "'{0}' in '{1}' has no public properties. If this is not a DTO, mark it with [NoDtoGeneration].", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor InfrastructureTypeInContracts = new( + id: "SM0038", + title: "Infrastructure type in Contracts assembly", + messageFormat: "'{0}' appears to be an infrastructure type in a Contracts assembly. Infrastructure types should not be in Contracts assemblies. Mark it with [NoDtoGeneration] or move it.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); +} diff --git a/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.DbContext.cs b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.DbContext.cs new file mode 100644 index 00000000..cdc80ead --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.DbContext.cs @@ -0,0 +1,60 @@ +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal static partial class DiagnosticDescriptors +{ + 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 + ); + + internal 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 + ); + + internal 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 + ); + + internal 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 + ); + + internal 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 EntityNotInContractsAssembly = new( + id: "SM0055", + title: "Entity class must live in a Contracts assembly", + messageFormat: "Entity '{0}' is exposed as DbSet '{1}' on '{2}' but is declared in assembly '{3}'. Entity classes must be declared in a '.Contracts' assembly so other modules can reference them type-safely through contracts. Move '{0}' to assembly '{4}' (or another '.Contracts' assembly that the module references).", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); +} diff --git a/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.Dependency.cs b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.Dependency.cs new file mode 100644 index 00000000..e58f1070 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.Dependency.cs @@ -0,0 +1,24 @@ +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal static partial class DiagnosticDescriptors +{ + 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 to handle those events. Learn more: https://docs.simplemodule.dev/module-dependencies.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor IllegalImplementationReference = new( + id: "SM0011", + title: "Module directly references another module's implementation", + messageFormat: "Module '{0}' directly references module '{1}' implementation assembly '{2}'. Modules must only depend on each other through Contracts packages. This creates tight coupling \u2014 internal changes in {1} can break {0} at compile time or runtime. To fix: (1) Remove the reference to '{2}'. (2) Add a reference to '{1}.Contracts' instead. (3) Replace any usage of internal {1} types with their contract interfaces. Learn more: https://docs.simplemodule.dev/module-contracts.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); +} diff --git a/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.Endpoint.cs b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.Endpoint.cs new file mode 100644 index 00000000..2e8cbc35 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.Endpoint.cs @@ -0,0 +1,78 @@ +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal static partial class DiagnosticDescriptors +{ + internal static readonly DiagnosticDescriptor DuplicateViewPageName = new( + id: "SM0015", + title: "Duplicate view page name across modules", + messageFormat: "View page name '{0}' is registered by multiple endpoints: '{1}' (module {2}) and '{3}' (module {4}). Each IViewEndpoint must map to a unique page name. Rename one of the endpoint classes or move it to a different module.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor ViewPagePrefixMismatch = new( + id: "SM0041", + title: "View page name does not match module name prefix", + messageFormat: "View endpoint '{0}' in module '{1}' maps to page '{2}', but page names should start with the module name prefix '{1}/'. This causes the React page resolver to look for the page bundle in the wrong module. Rename the endpoint class or move it to the correct module.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor ViewEndpointWithoutViewPrefix = new( + id: "SM0042", + title: "Module has view endpoints but no ViewPrefix", + messageFormat: "Module '{0}' contains {1} IViewEndpoint implementation(s) but does not define a ViewPrefix. View endpoints will not be routed correctly. Add ViewPrefix to the [Module] attribute: [Module(\"{0}\", ViewPrefix = \"/{2}\")].", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor InterceptorDependsOnDbContext = new( + id: "SM0039", + title: "SaveChanges interceptor has transitive DbContext dependency", + messageFormat: "ISaveChangesInterceptor '{0}' in module '{1}' has a constructor parameter '{2}' whose implementation depends on a DbContext. This creates a circular dependency when ModuleDbContextOptionsBuilder resolves interceptors from DI during DbContext options construction. To fix: make the parameter optional and resolve it lazily, or remove the dependency.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor MultipleEndpointsPerFile = new( + id: "SM0049", + title: "Multiple endpoints in a single file", + messageFormat: "File '{0}' contains multiple endpoint classes ({1}). Each endpoint must be in its own file for maintainability and to match the Pages/index.ts convention.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor ModuleAssemblyNamingViolation = new( + id: "SM0052", + title: "Module assembly name does not follow naming convention", + messageFormat: "Module '{0}' is in assembly '{1}', but the assembly name must be 'SimpleModule.{0}' (or 'SimpleModule.{0}.Module' when a framework assembly with the same base name exists). Rename the project/assembly to follow the standard module naming convention.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor MissingContractsAssembly = new( + id: "SM0053", + title: "Module has no matching Contracts assembly", + messageFormat: "Module '{0}' (assembly '{1}') has no matching Contracts assembly. Every module must have a 'SimpleModule.{0}.Contracts' project with at least one public interface. Create the project and add a reference to it.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor MissingEndpointRouteConst = new( + id: "SM0054", + title: "Endpoint missing Route const field", + messageFormat: "Endpoint '{0}' does not declare a 'public const string Route' field. Add a Route const so the source generator can emit type-safe route helpers.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true + ); +} diff --git a/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.Module.cs b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.Module.cs new file mode 100644 index 00000000..4a2c54b4 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.Module.cs @@ -0,0 +1,33 @@ +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal static partial class DiagnosticDescriptors +{ + internal 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 + ); + + internal static readonly DiagnosticDescriptor DuplicateModuleName = new( + id: "SM0040", + title: "Duplicate module name", + messageFormat: "Module name '{0}' is used by both '{1}' and '{2}'. Each module must have a unique name. Duplicate names cause route prefix conflicts, database schema collisions, and ambiguous TypeScript module grouping.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor EmptyModuleWarning = new( + id: "SM0043", + title: "Module does not override any IModule methods", + messageFormat: "Module '{0}' implements IModule but does not override any configuration methods (ConfigureServices, ConfigureMenu, etc.). This module will be discovered but has no effect. If this is intentional, add at least ConfigureServices with a comment explaining why.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); +} diff --git a/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.PermissionFeature.cs b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.PermissionFeature.cs new file mode 100644 index 00000000..e4da2a8e --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.PermissionFeature.cs @@ -0,0 +1,96 @@ +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +internal static partial class DiagnosticDescriptors +{ + internal static readonly DiagnosticDescriptor PermissionFieldNotConstString = new( + id: "SM0027", + title: "Permission field is not a const string", + messageFormat: "Permission class '{0}' must contain only public const string fields. Found field '{1}' that is not a const string.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor PermissionValueBadPattern = new( + id: "SM0031", + title: "Permission value does not follow naming pattern", + messageFormat: "Permission value '{0}' in '{1}' should follow the 'Module.Action' pattern, for example 'Products.View'", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor PermissionClassNotSealed = new( + id: "SM0032", + title: "Permission class is not sealed", + messageFormat: "'{0}' implements IModulePermissions but is not sealed. Permission classes must be sealed.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor DuplicatePermissionValue = new( + id: "SM0033", + title: "Duplicate permission value", + messageFormat: "Permission value '{0}' is defined in both '{1}' and '{2}'. Each permission value must be unique.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor PermissionValueWrongPrefix = new( + id: "SM0034", + title: "Permission value prefix does not match module name", + messageFormat: "Permission '{0}' is defined in module '{1}'. Permission values should be prefixed with the owning module name.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor MultipleModuleOptions = new( + id: "SM0044", + title: "Multiple IModuleOptions for same module", + messageFormat: "Module '{0}' has multiple IModuleOptions implementations: '{1}' and '{2}'. Each module should have at most one options class. Only the first will be used.", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor FeatureClassNotSealed = new( + id: "SM0045", + title: "Feature class is not sealed", + messageFormat: "'{0}' implements IModuleFeatures but is not sealed", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor FeatureFieldNamingViolation = new( + id: "SM0046", + title: "Feature field naming violation", + messageFormat: "Feature '{0}' in '{1}' does not follow the 'ModuleName.FeatureName' pattern", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor DuplicateFeatureName = new( + id: "SM0047", + title: "Duplicate feature name", + messageFormat: "Feature name '{0}' is defined in both '{1}' and '{2}'", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + internal static readonly DiagnosticDescriptor FeatureFieldNotConstString = new( + id: "SM0048", + title: "Feature field is not a const string", + messageFormat: "Field '{0}' in feature class '{1}' must be a public const string", + category: "SimpleModule.Generator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); +} diff --git a/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.cs b/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.cs deleted file mode 100644 index d131830b..00000000 --- a/framework/SimpleModule.Generator/Emitters/Diagnostics/DiagnosticDescriptors.cs +++ /dev/null @@ -1,348 +0,0 @@ -using Microsoft.CodeAnalysis; - -namespace SimpleModule.Generator; - -internal static class DiagnosticDescriptors -{ - 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 - ); - - internal 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 - ); - - internal 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 - ); - - internal 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 - ); - - internal 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 - ); - - internal 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 to handle those events. Learn more: https://docs.simplemodule.dev/module-dependencies.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor IllegalImplementationReference = new( - id: "SM0011", - title: "Module directly references another module's implementation", - messageFormat: "Module '{0}' directly references module '{1}' implementation assembly '{2}'. Modules must only depend on each other through Contracts packages. This creates tight coupling \u2014 internal changes in {1} can break {0} at compile time or runtime. To fix: (1) Remove the reference to '{2}'. (2) Add a reference to '{1}.Contracts' instead. (3) Replace any usage of internal {1} types with their contract interfaces. Learn more: https://docs.simplemodule.dev/module-contracts.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor ContractInterfaceTooLargeWarning = new( - id: "SM0012", - title: "Contract interface has too many methods", - messageFormat: "Contract interface '{0}' has {1} methods, which exceeds the recommended maximum of 15. Large contract interfaces force consuming modules to depend on methods they don't use. Consider splitting into focused interfaces (e.g., I{2}Queries, I{2}Commands). Your module class can implement all of them. Warning threshold: 15 methods, error threshold: 20 methods. Learn more: https://docs.simplemodule.dev/contract-design.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor ContractInterfaceTooLargeError = new( - id: "SM0013", - title: "Contract interface must be split", - messageFormat: "Contract interface '{0}' has {1} methods and must be split before the project will compile. Interfaces with more than 20 methods are not allowed. Split into focused interfaces (e.g., I{2}Queries, I{2}Commands). Your module class can implement all of them. Warning threshold: 15 methods, error threshold: 20 methods. Learn more: https://docs.simplemodule.dev/contract-design.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor MissingContractInterfaces = new( - id: "SM0014", - title: "Referenced contracts assembly has no public interfaces", - messageFormat: "Module '{0}' references '{1}' but no contract interfaces were found in that assembly. Likely causes: (1) Incompatible package version \u2014 check with 'dotnet list package --include-transitive'. (2) The Contracts project is empty or not yet built. (3) The package is corrupted \u2014 try 'dotnet nuget locals all --clear' then 'dotnet restore'. Verify that the version of {1} you're using exports the interfaces your code depends on. Learn more: https://docs.simplemodule.dev/package-compatibility.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor DuplicateViewPageName = new( - id: "SM0015", - title: "Duplicate view page name across modules", - messageFormat: "View page name '{0}' is registered by multiple endpoints: '{1}' (module {2}) and '{3}' (module {4}). Each IViewEndpoint must map to a unique page name. Rename one of the endpoint classes or move it to a different module.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor NoContractImplementation = new( - id: "SM0025", - title: "No implementation found for contract interface", - messageFormat: "No implementation of '{0}' found in module '{1}'. Add a public class implementing this interface.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor MultipleContractImplementations = new( - id: "SM0026", - title: "Multiple implementations of contract interface", - messageFormat: "Multiple implementations of '{0}' found in module '{1}': {2}. Only one implementation per contract interface is allowed.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor PermissionFieldNotConstString = new( - id: "SM0027", - title: "Permission field is not a const string", - messageFormat: "Permission class '{0}' must contain only public const string fields. Found field '{1}' that is not a const string.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor ContractImplementationNotPublic = new( - id: "SM0028", - title: "Contract implementation is not public", - messageFormat: "Implementation '{0}' of '{1}' must be public. The DI container cannot access internal types across assemblies.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor ContractImplementationIsAbstract = new( - id: "SM0029", - title: "Contract implementation is abstract", - messageFormat: "'{0}' implements '{1}' but is abstract. Provide a concrete implementation.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor PermissionValueBadPattern = new( - id: "SM0031", - title: "Permission value does not follow naming pattern", - messageFormat: "Permission value '{0}' in '{1}' should follow the 'Module.Action' pattern, for example 'Products.View'", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor PermissionClassNotSealed = new( - id: "SM0032", - title: "Permission class is not sealed", - messageFormat: "'{0}' implements IModulePermissions but is not sealed. Permission classes must be sealed.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor DuplicatePermissionValue = new( - id: "SM0033", - title: "Duplicate permission value", - messageFormat: "Permission value '{0}' is defined in both '{1}' and '{2}'. Each permission value must be unique.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor PermissionValueWrongPrefix = new( - id: "SM0034", - title: "Permission value prefix does not match module name", - messageFormat: "Permission '{0}' is defined in module '{1}'. Permission values should be prefixed with the owning module name.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor DtoTypeNoProperties = new( - id: "SM0035", - title: "DTO type in contracts has no public properties", - messageFormat: "'{0}' in '{1}' has no public properties. If this is not a DTO, mark it with [NoDtoGeneration].", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor InfrastructureTypeInContracts = new( - id: "SM0038", - title: "Infrastructure type in Contracts assembly", - messageFormat: "'{0}' appears to be an infrastructure type in a Contracts assembly. Infrastructure types should not be in Contracts assemblies. Mark it with [NoDtoGeneration] or move it.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor InterceptorDependsOnDbContext = new( - id: "SM0039", - title: "SaveChanges interceptor has transitive DbContext dependency", - messageFormat: "ISaveChangesInterceptor '{0}' in module '{1}' has a constructor parameter '{2}' whose implementation depends on a DbContext. This creates a circular dependency when ModuleDbContextOptionsBuilder resolves interceptors from DI during DbContext options construction. To fix: make the parameter optional and resolve it lazily, or remove the dependency.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor DuplicateModuleName = new( - id: "SM0040", - title: "Duplicate module name", - messageFormat: "Module name '{0}' is used by both '{1}' and '{2}'. Each module must have a unique name. Duplicate names cause route prefix conflicts, database schema collisions, and ambiguous TypeScript module grouping.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor ViewPagePrefixMismatch = new( - id: "SM0041", - title: "View page name does not match module name prefix", - messageFormat: "View endpoint '{0}' in module '{1}' maps to page '{2}', but page names should start with the module name prefix '{1}/'. This causes the React page resolver to look for the page bundle in the wrong module. Rename the endpoint class or move it to the correct module.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor ViewEndpointWithoutViewPrefix = new( - id: "SM0042", - title: "Module has view endpoints but no ViewPrefix", - messageFormat: "Module '{0}' contains {1} IViewEndpoint implementation(s) but does not define a ViewPrefix. View endpoints will not be routed correctly. Add ViewPrefix to the [Module] attribute: [Module(\"{0}\", ViewPrefix = \"/{2}\")].", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor EmptyModuleWarning = new( - id: "SM0043", - title: "Module does not override any IModule methods", - messageFormat: "Module '{0}' implements IModule but does not override any configuration methods (ConfigureServices, ConfigureMenu, etc.). This module will be discovered but has no effect. If this is intentional, add at least ConfigureServices with a comment explaining why.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor MultipleModuleOptions = new( - id: "SM0044", - title: "Multiple IModuleOptions for same module", - messageFormat: "Module '{0}' has multiple IModuleOptions implementations: '{1}' and '{2}'. Each module should have at most one options class. Only the first will be used.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor FeatureClassNotSealed = new( - id: "SM0045", - title: "Feature class is not sealed", - messageFormat: "'{0}' implements IModuleFeatures but is not sealed", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor FeatureFieldNamingViolation = new( - id: "SM0046", - title: "Feature field naming violation", - messageFormat: "Feature '{0}' in '{1}' does not follow the 'ModuleName.FeatureName' pattern", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor DuplicateFeatureName = new( - id: "SM0047", - title: "Duplicate feature name", - messageFormat: "Feature name '{0}' is defined in both '{1}' and '{2}'", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor FeatureFieldNotConstString = new( - id: "SM0048", - title: "Feature field is not a const string", - messageFormat: "Field '{0}' in feature class '{1}' must be a public const string", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor MultipleEndpointsPerFile = new( - id: "SM0049", - title: "Multiple endpoints in a single file", - messageFormat: "File '{0}' contains multiple endpoint classes ({1}). Each endpoint must be in its own file for maintainability and to match the Pages/index.ts convention.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor ModuleAssemblyNamingViolation = new( - id: "SM0052", - title: "Module assembly name does not follow naming convention", - messageFormat: "Module '{0}' is in assembly '{1}', but the assembly name must be 'SimpleModule.{0}' (or 'SimpleModule.{0}.Module' when a framework assembly with the same base name exists). Rename the project/assembly to follow the standard module naming convention.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor MissingContractsAssembly = new( - id: "SM0053", - title: "Module has no matching Contracts assembly", - messageFormat: "Module '{0}' (assembly '{1}') has no matching Contracts assembly. Every module must have a 'SimpleModule.{0}.Contracts' project with at least one public interface. Create the project and add a reference to it.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor MissingEndpointRouteConst = new( - id: "SM0054", - title: "Endpoint missing Route const field", - messageFormat: "Endpoint '{0}' does not declare a 'public const string Route' field. Add a Route const so the source generator can emit type-safe route helpers.", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Info, - isEnabledByDefault: true - ); - - internal static readonly DiagnosticDescriptor EntityNotInContractsAssembly = new( - id: "SM0055", - title: "Entity class must live in a Contracts assembly", - messageFormat: "Entity '{0}' is exposed as DbSet '{1}' on '{2}' but is declared in assembly '{3}'. Entity classes must be declared in a '.Contracts' assembly so other modules can reference them type-safely through contracts. Move '{0}' to assembly '{4}' (or another '.Contracts' assembly that the module references).", - category: "SimpleModule.Generator", - defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true - ); -}