From f2037dd8e614d2efdd71e22ffa46b6f4b94003b9 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 10:44:17 +0200 Subject: [PATCH 01/31] feat(core): module manifest model, attribute and reader --- .../2026-06-10-module-packaging-session1.md | 298 ++++++++++++++++++ .../Modules/ModuleManifest.cs | 68 ++++ .../Modules/ModuleManifestAttribute.cs | 15 + .../Modules/ModuleManifestReader.cs | 74 +++++ .../Modules/ModuleManifestTests.cs | 104 ++++++ 5 files changed, 559 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-module-packaging-session1.md create mode 100644 framework/SimpleModule.Core/Modules/ModuleManifest.cs create mode 100644 framework/SimpleModule.Core/Modules/ModuleManifestAttribute.cs create mode 100644 framework/SimpleModule.Core/Modules/ModuleManifestReader.cs create mode 100644 tests/SimpleModule.Core.Tests/Modules/ModuleManifestTests.cs diff --git a/docs/superpowers/plans/2026-06-10-module-packaging-session1.md b/docs/superpowers/plans/2026-06-10-module-packaging-session1.md new file mode 100644 index 00000000..f9a3d164 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-module-packaging-session1.md @@ -0,0 +1,298 @@ +# Module Packaging — Session 1 (Package Contract) 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:** Every SimpleModule module assembly carries a machine-readable JSON manifest emitted at compile time; the host discovers module frontend bundles via that manifest instead of filesystem probing; module DbContexts that ship EF migrations get them applied; an in-repo module (FeatureFlags) loads end-to-end as a packed nupkg from a local folder feed. + +**Architecture:** A new `ModuleManifestEmitter` in the Roslyn generator emits `[assembly: ModuleManifest("{json}")]` into each module assembly (source generators cannot emit literal embedded resources — the assembly-level attribute is the closest equivalent readable both via reflection and via `System.Reflection.Metadata` without loading; `sm pack` in Session 2 will additionally extract it to a `module-manifest.json` in the nupkg). The generator gains a `SimpleModuleProjectKind` analyzer-config switch: `Module` projects get ONLY the manifest emitter; hosts keep current behavior. Hosting builds a `ModuleManifestRegistry` from the DI-registered `IModule` instances and injects a `` right before the `data-page` script. Empty map → emit nothing. +- Test: integration test using `SimpleModuleWebApplicationFactory` — GET a view page, assert response HTML contains `id="sm-module-assets"` and the FeatureFlags entry. Place beside existing hosting/host integration tests (find with `grep -rl SimpleModuleWebApplicationFactory tests/ | head`). + +- [ ] Steps: failing test → implement → pass → **Commit** `feat(hosting): module manifest registry and frontend asset map injection` + +### Task 6: Client — manifest-first page resolution + +**Files:** +- Modify: `packages/SimpleModule.Client/src/resolve-page.ts` + +```typescript +let moduleAssets: Record | null | undefined; +function getModuleAssets(): Record | null { + if (moduleAssets !== undefined) return moduleAssets; + const el = document.getElementById('sm-module-assets'); + moduleAssets = el?.textContent ? JSON.parse(el.textContent) : null; + return moduleAssets; +} +// In resolvePage: before probing, const entry = getModuleAssets()?.[moduleName]; +// if entry → import(`/${entry}${suffix}`) and on success skip candidate loop. +// On failure (or no manifest) fall through to existing candidates probing. +``` + +- [ ] **Step 1:** implement (keep fallback intact, JSON.parse wrapped in try/catch → null). **Step 2:** `npm run check` green; `npm run dev:build` green. **Step 3: Commit** `feat(client): resolve module bundles via sm-module-assets manifest map` + +### Task 7: Database — apply migrations for module contexts that ship them + +**Files:** +- Modify: `framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs:189-209` + +```csharp +foreach (var info in infos) +{ + if (scope.ServiceProvider.GetService(info.DbContextType) is not DbContext db) + continue; + + var hasMigrations = db.Database.GetMigrations().Any(); + if (info.ModuleName == DatabaseConstants.HostModuleName) + { + if (hasMigrations) await db.Database.MigrateAsync(); + else await db.Database.EnsureCreatedAsync(); + } + else if (hasMigrations) + { + // Packaged modules ship their own EF migrations (EnsureCreated is not + // acceptable for installed modules). In-repo module contexts without + // migrations still get their schema from the unified HostDbContext. + await db.Database.MigrateAsync(); + } +} +``` + +- [ ] Test: existing host startup integration tests stay green (`dotnet test`), behavior unchanged for migration-less contexts. Document the EnsureCreated→Migrate transition caveat in the design doc. **Commit** `feat(hosting): apply EF migrations for module DbContexts that bundle them` + +### Task 8: Design doc + +**Files:** +- Create: `docs/site/advanced/module-packaging.md` — manifest schema v1 (table per field + JSON example), nupkg layout (lib/net10.0 assembly + contracts package + `staticwebassets/` tree + migrations inside the module assembly), frontend externals contract (react, react-dom, @inertiajs/react, SimpleModule.UI host-provided; validated at pack time in Session 2), version compat rules (`frameworkCompat` semantics, 0.x caveat, override property), the embedded-resource-vs-attribute deviation rationale, migration application contract, and the `simplemodule-module` tag convention. +- Check `docs/site/.vitepress/config.*` (or equivalent sidebar config) and add the page to the Advanced sidebar if pages are listed explicitly. + +- [ ] Write doc → `npm run check` (if docs are covered) → **Commit** `docs: module packaging contract (manifest schema v1, nupkg layout, externals)` + +### Task 9: Checkpoint — FeatureFlags as a packed nupkg + +- [ ] **Step 1:** Pack to a local feed (Version defaults to 1.0.0, matching project-reference identities so NuGet unifies Core deps with in-solution projects): + +```bash +FEED=$CLAUDE_JOB_DIR/tmp/local-feed && mkdir -p $FEED +dotnet pack modules/FeatureFlags/src/SimpleModule.FeatureFlags.Contracts -o $FEED +dotnet pack modules/FeatureFlags/src/SimpleModule.FeatureFlags -o $FEED +unzip -l $FEED/SimpleModule.FeatureFlags.1.0.0.nupkg | grep -E "pages.js|dll" # static assets present +``` + +- [ ] **Step 2:** In `template/SimpleModule.Host/SimpleModule.Host.csproj` swap the two FeatureFlags `ProjectReference`s for `` (temporary, working tree only). +- [ ] **Step 3:** `dotnet restore template/SimpleModule.Host -p:RestoreAdditionalProjectSources=$FEED && dotnet build template/SimpleModule.Host` — green. +- [ ] **Step 4:** Run the host, then verify: `curl -sk https://localhost:5001/feature-flags` returns HTML containing `sm-module-assets` with the FeatureFlags entry; `curl -sk https://localhost:5001/_content/SimpleModule.FeatureFlags/SimpleModule.FeatureFlags.pages.js -o /dev/null -w '%{http_code}'` → 200. Drive the page in a browser (playwright) to confirm React renders. +- [ ] **Step 5:** Revert the Host csproj swap (`git checkout template/SimpleModule.Host/SimpleModule.Host.csproj`); record results in the checkpoint report. + +### Task 10: Full verification + +- [ ] `dotnet build` (TreatWarningsAsErrors — zero warnings) +- [ ] `dotnet test` (all) +- [ ] `npm run check` + `npm run validate-pages` + `npm run build:dev` +- [ ] Write checkpoint report: frozen API surface (ModuleManifestAttribute ctor, manifest schema v1 field set, `sm-module-assets` element id, `SimpleModuleProjectKind`/`SimpleModuleFrameworkCompat` build properties, `IModuleManifestRegistry`), framework friction found (GitHub issues labeled `packaging`), assumptions made. + +--- + +## Self-review notes + +- Spec coverage: manifest schema/emission ✔ (Tasks 1-4), frontend loading via manifest ✔ (5-6), migrations hook ✔ (7), design doc ✔ (8), checkpoint ✔ (9). Marketplace audit was completed pre-plan (module already deleted in b2698964; recommendation: leave deleted). +- Deviation from spec: "embedded resource" → assembly-level attribute (Roslyn limitation); documented in Task 8 and the checkpoint report. +- Out of scope kept out: no CLI commands (Session 2), no publish/search (Session 3), no custom registry. diff --git a/framework/SimpleModule.Core/Modules/ModuleManifest.cs b/framework/SimpleModule.Core/Modules/ModuleManifest.cs new file mode 100644 index 00000000..89d1545f --- /dev/null +++ b/framework/SimpleModule.Core/Modules/ModuleManifest.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; + +namespace SimpleModule.Core.Modules; + +/// +/// Compile-time metadata describing a module: identity, framework compatibility, +/// declared permissions, frontend entry asset, and the domain events it publishes +/// and consumes. Emitted into each module assembly by SimpleModule.Generator as a +/// and read back via +/// . +/// +public sealed class ModuleManifest +{ + /// Manifest schema version this assembly was compiled against. + public int SchemaVersion { get; init; } + + /// Package/assembly identity, e.g. SimpleModule.FeatureFlags. + public string Id { get; init; } = ""; + + /// Module name from the [Module] attribute, e.g. FeatureFlags. + public string Name { get; init; } = ""; + + /// Human-readable name; defaults to when not customized. + public string DisplayName { get; init; } = ""; + + /// Module version from the [Module] attribute. + public string Version { get; init; } = ""; + + /// + /// SemVer range of SimpleModule.Core versions this module was built for, + /// e.g. >=0.0.38 <1.0.0. + /// + public string FrameworkCompat { get; init; } = ""; + + /// API route prefix, e.g. /api/feature-flags. + public string RoutePrefix { get; init; } = ""; + + /// View route prefix, e.g. /feature-flags. + public string ViewPrefix { get; init; } = ""; + + /// + /// Database schema/prefix name — the module name used as the + /// ModuleConnections configuration key. + /// + public string Schema { get; init; } = ""; + + /// Permission values declared by the module's permission classes. + public IReadOnlyList Permissions { get; init; } = []; + + /// + /// Static web asset path of the module's prebuilt frontend bundle relative to + /// the web root (e.g. _content/SimpleModule.X/SimpleModule.X.pages.js), + /// or null when the module ships no frontend pages. + /// + public string? FrontendEntry { get; init; } + + /// Inertia page names served by the module, e.g. X/Browse. + public IReadOnlyList Pages { get; init; } = []; + + /// Fully-qualified names of DomainEvent types declared by the module. + public IReadOnlyList EventsPublished { get; init; } = []; + + /// Fully-qualified names of DomainEvent types handled by the module. + public IReadOnlyList EventsConsumed { get; init; } = []; + + /// Whether the module owns its own DbContext. + public bool HasDbContext { get; init; } +} diff --git a/framework/SimpleModule.Core/Modules/ModuleManifestAttribute.cs b/framework/SimpleModule.Core/Modules/ModuleManifestAttribute.cs new file mode 100644 index 00000000..89c506e7 --- /dev/null +++ b/framework/SimpleModule.Core/Modules/ModuleManifestAttribute.cs @@ -0,0 +1,15 @@ +using System; + +namespace SimpleModule.Core.Modules; + +/// +/// Carries the compile-time module manifest JSON emitted by SimpleModule.Generator. +/// Assembly-level so tooling can read it via System.Reflection.Metadata without +/// loading the assembly. Source generators cannot add embedded resources, which is +/// why the manifest travels as an attribute rather than a resource stream. +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)] +public sealed class ModuleManifestAttribute(string json) : Attribute +{ + public string Json { get; } = json; +} diff --git a/framework/SimpleModule.Core/Modules/ModuleManifestReader.cs b/framework/SimpleModule.Core/Modules/ModuleManifestReader.cs new file mode 100644 index 00000000..9803fe3b --- /dev/null +++ b/framework/SimpleModule.Core/Modules/ModuleManifestReader.cs @@ -0,0 +1,74 @@ +using System; +using System.Reflection; +using System.Text.Json; + +namespace SimpleModule.Core.Modules; + +/// Thrown when a module manifest cannot be parsed or is incompatible. +public sealed class ModuleManifestException : Exception +{ + public ModuleManifestException(string message) + : base(message) { } + + public ModuleManifestException(string message, Exception innerException) + : base(message, innerException) { } + + public ModuleManifestException() { } +} + +/// +/// Reads instances from manifest JSON or from the +/// on a module assembly. +/// +public static class ModuleManifestReader +{ + /// Highest manifest schema version this framework build understands. + public const int CurrentSchemaVersion = 1; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + public static ModuleManifest Parse(string json) + { + ModuleManifest? manifest; + try + { + manifest = JsonSerializer.Deserialize(json, SerializerOptions); + } + catch (JsonException ex) + { + throw new ModuleManifestException("Module manifest is not valid JSON.", ex); + } + + if (manifest is null) + { + throw new ModuleManifestException("Module manifest JSON deserialized to null."); + } + + if (manifest.SchemaVersion > CurrentSchemaVersion) + { + throw new ModuleManifestException( + $"Module manifest schemaVersion {manifest.SchemaVersion} is newer than the " + + $"highest supported version {CurrentSchemaVersion}. Update the SimpleModule " + + "framework packages in the host to use this module." + ); + } + + return manifest; + } + + /// + /// Reads the manifest from , or returns null + /// when the assembly carries no (e.g. a + /// module compiled before manifest emission existed). + /// + public static ModuleManifest? TryRead(Assembly assembly) + { + ArgumentNullException.ThrowIfNull(assembly); + var attribute = assembly.GetCustomAttribute(); + return attribute is null ? null : Parse(attribute.Json); + } +} diff --git a/tests/SimpleModule.Core.Tests/Modules/ModuleManifestTests.cs b/tests/SimpleModule.Core.Tests/Modules/ModuleManifestTests.cs new file mode 100644 index 00000000..d2b78e24 --- /dev/null +++ b/tests/SimpleModule.Core.Tests/Modules/ModuleManifestTests.cs @@ -0,0 +1,104 @@ +using FluentAssertions; +using SimpleModule.Core.Modules; + +namespace SimpleModule.Core.Tests.Modules; + +public class ModuleManifestTests +{ + private const string SchemaV1Json = """ + { + "schemaVersion": 1, + "id": "SimpleModule.X", + "name": "X", + "displayName": "X Module", + "version": "1.0.0", + "frameworkCompat": ">=0.0.38 <1.0.0", + "routePrefix": "/api/x", + "viewPrefix": "/x", + "schema": "X", + "permissions": ["X.View", "X.Manage"], + "frontendEntry": "_content/SimpleModule.X/SimpleModule.X.pages.js", + "pages": ["X/Browse"], + "eventsPublished": ["SimpleModule.X.Contracts.ThingHappened"], + "eventsConsumed": ["SimpleModule.Y.Contracts.OtherThing"], + "hasDbContext": true + } + """; + + [Fact] + public void Parse_RoundtripsSchemaV1Json() + { + var manifest = ModuleManifestReader.Parse(SchemaV1Json); + + manifest.SchemaVersion.Should().Be(1); + manifest.Id.Should().Be("SimpleModule.X"); + manifest.Name.Should().Be("X"); + manifest.DisplayName.Should().Be("X Module"); + manifest.Version.Should().Be("1.0.0"); + manifest.FrameworkCompat.Should().Be(">=0.0.38 <1.0.0"); + manifest.RoutePrefix.Should().Be("/api/x"); + manifest.ViewPrefix.Should().Be("/x"); + manifest.Schema.Should().Be("X"); + manifest.Permissions.Should().Equal("X.View", "X.Manage"); + manifest.FrontendEntry.Should().Be("_content/SimpleModule.X/SimpleModule.X.pages.js"); + manifest.Pages.Should().Equal("X/Browse"); + manifest.EventsPublished.Should().Equal("SimpleModule.X.Contracts.ThingHappened"); + manifest.EventsConsumed.Should().Equal("SimpleModule.Y.Contracts.OtherThing"); + manifest.HasDbContext.Should().BeTrue(); + } + + [Fact] + public void Parse_ToleratesUnknownFieldsForForwardCompat() + { + var manifest = ModuleManifestReader.Parse( + """{"schemaVersion":1,"id":"A","name":"A","someFutureField":{"x":1}}""" + ); + + manifest.Id.Should().Be("A"); + } + + [Fact] + public void Parse_AllowsMissingOptionalFields() + { + var manifest = ModuleManifestReader.Parse("""{"schemaVersion":1,"id":"A","name":"A"}"""); + + manifest.FrontendEntry.Should().BeNull(); + manifest.Permissions.Should().BeEmpty(); + manifest.Pages.Should().BeEmpty(); + manifest.EventsPublished.Should().BeEmpty(); + manifest.EventsConsumed.Should().BeEmpty(); + manifest.HasDbContext.Should().BeFalse(); + } + + [Fact] + public void Parse_ThrowsOnInvalidJson() + { + var act = () => ModuleManifestReader.Parse("not json"); + + act.Should().Throw(); + } + + [Fact] + public void Parse_ThrowsOnNewerSchemaVersion() + { + var act = () => ModuleManifestReader.Parse("""{"schemaVersion":999,"id":"A","name":"A"}"""); + + act.Should().Throw().WithMessage("*schemaVersion*999*"); + } + + [Fact] + public void TryRead_ReturnsNullForAssemblyWithoutManifest() + { + var manifest = ModuleManifestReader.TryRead(typeof(object).Assembly); + + manifest.Should().BeNull(); + } + + [Fact] + public void ModuleManifestAttribute_ExposesJson() + { + var attribute = new ModuleManifestAttribute("{}"); + + attribute.Json.Should().Be("{}"); + } +} From 6fe8813a15cf5cd82c0b23f3f6b74b25a9ba4065 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 10:47:59 +0200 Subject: [PATCH 02/31] feat(generator): discover domain events and Wolverine-convention handlers --- .../Discovery/DiscoveryData.cs | 16 +- .../Discovery/DiscoveryDataBuilder.cs | 10 +- .../Discovery/Finders/EventFinder.cs | 170 ++++++++++++++++++ .../Discovery/Records/ModuleRecords.cs | 7 + .../Discovery/SymbolDiscovery.cs | 33 +++- .../EventDiscoveryTests.cs | 80 +++++++++ .../TopologicalSortTests.cs | 15 +- 7 files changed, 323 insertions(+), 8 deletions(-) create mode 100644 framework/SimpleModule.Generator/Discovery/Finders/EventFinder.cs create mode 100644 tests/SimpleModule.Generator.Tests/EventDiscoveryTests.cs diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs index cbbc651c..81ce92bf 100644 --- a/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryData.cs @@ -48,10 +48,13 @@ internal readonly record struct DiscoveryData( ImmutableArray AgentToolProviders, ImmutableArray KnowledgeSources, ImmutableArray FormRequests, + ImmutableArray EventTypes, + ImmutableArray EventHandlers, ImmutableArray ContractsAssemblyNames, bool HasAgentsAssembly, bool HasRagAssembly, - string HostAssemblyName + string HostAssemblyName, + string CoreAssemblyVersion ) { public bool HasAnyAgentContent => @@ -81,9 +84,12 @@ string HostAssemblyName ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty, ImmutableArray.Empty, false, false, + "", "" ); @@ -106,10 +112,13 @@ public bool Equals(DiscoveryData other) && AgentToolProviders.SequenceEqual(other.AgentToolProviders) && KnowledgeSources.SequenceEqual(other.KnowledgeSources) && FormRequests.SequenceEqual(other.FormRequests) + && EventTypes.SequenceEqual(other.EventTypes) + && EventHandlers.SequenceEqual(other.EventHandlers) && ContractsAssemblyNames.SequenceEqual(other.ContractsAssemblyNames) && HasAgentsAssembly == other.HasAgentsAssembly && HasRagAssembly == other.HasRagAssembly - && HostAssemblyName == other.HostAssemblyName; + && HostAssemblyName == other.HostAssemblyName + && CoreAssemblyVersion == other.CoreAssemblyVersion; } public override int GetHashCode() @@ -132,10 +141,13 @@ public override int GetHashCode() hash = HashHelper.HashArray(hash, AgentToolProviders); hash = HashHelper.HashArray(hash, KnowledgeSources); hash = HashHelper.HashArray(hash, FormRequests); + hash = HashHelper.HashArray(hash, EventTypes); + hash = HashHelper.HashArray(hash, EventHandlers); hash = HashHelper.HashArray(hash, ContractsAssemblyNames); hash = HashHelper.Combine(hash, HasAgentsAssembly.GetHashCode()); hash = HashHelper.Combine(hash, HasRagAssembly.GetHashCode()); hash = HashHelper.Combine(hash, (HostAssemblyName ?? "").GetHashCode()); + hash = HashHelper.Combine(hash, (CoreAssemblyVersion ?? "").GetHashCode()); return hash; } } diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryDataBuilder.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryDataBuilder.cs index 1a577891..c032cc6b 100644 --- a/framework/SimpleModule.Generator/Discovery/DiscoveryDataBuilder.cs +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryDataBuilder.cs @@ -30,10 +30,13 @@ internal static DiscoveryData Build( List agentToolProviders, List knowledgeSources, List formRequests, + List eventTypes, + List eventHandlers, Dictionary contractsAssemblyMap, bool hasAgentsAssembly, bool hasRagAssembly, - string hostAssemblyName + string hostAssemblyName, + string coreAssemblyVersion ) { return new DiscoveryData( @@ -184,10 +187,13 @@ string hostAssemblyName f.Location )) .ToImmutableArray(), + eventTypes.ToImmutableArray(), + eventHandlers.ToImmutableArray(), contractsAssemblyMap.Keys.ToImmutableArray(), hasAgentsAssembly, hasRagAssembly, - hostAssemblyName + hostAssemblyName, + coreAssemblyVersion ); } } diff --git a/framework/SimpleModule.Generator/Discovery/Finders/EventFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/EventFinder.cs new file mode 100644 index 00000000..ca6a0020 --- /dev/null +++ b/framework/SimpleModule.Generator/Discovery/Finders/EventFinder.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Microsoft.CodeAnalysis; + +namespace SimpleModule.Generator; + +/// +/// Discovers domain events a module publishes (types implementing +/// SimpleModule.Core.Events.IEvent declared in the module's implementation +/// or contracts assembly) and consumes (first parameters of Wolverine-convention +/// handler methods: classes named *Handler/*Consumer with a public +/// Handle/HandleAsync/Consume/ConsumeAsync method). +/// +internal static class EventFinder +{ + private static readonly string[] HandlerMethodNames = + [ + "Handle", + "HandleAsync", + "Consume", + "ConsumeAsync", + ]; + + internal static void Discover( + List modules, + Dictionary moduleSymbols, + Dictionary contractsAssemblySymbols, + Dictionary contractsAssemblyMap, + CoreSymbols s, + List eventTypes, + List eventHandlers, + CancellationToken cancellationToken + ) + { + if (s.EventInterface is null) + return; + + // An assembly is walked once even if it declares several [Module] classes; + // its events are attributed to the first module encountered. + var walkedAssemblies = new HashSet(StringComparer.Ordinal); + + foreach (var module in modules) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!moduleSymbols.TryGetValue(module.FullyQualifiedName, out var moduleSymbol)) + continue; + + var implAssembly = moduleSymbol.ContainingAssembly; + if (walkedAssemblies.Add(implAssembly.Name)) + { + Walk( + implAssembly.GlobalNamespace, + s.EventInterface, + module.ModuleName, + eventTypes, + eventHandlers, + cancellationToken + ); + } + } + + foreach (var kvp in contractsAssemblyMap) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!contractsAssemblySymbols.TryGetValue(kvp.Key, out var contractsAssembly)) + continue; + + if (walkedAssemblies.Add(contractsAssembly.Name)) + { + Walk( + contractsAssembly.GlobalNamespace, + s.EventInterface, + kvp.Value, + eventTypes, + eventHandlers, + cancellationToken + ); + } + } + } + + private static void Walk( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol eventInterface, + string moduleName, + List eventTypes, + List eventHandlers, + CancellationToken cancellationToken + ) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (member is INamespaceSymbol childNs) + { + Walk( + childNs, + eventInterface, + moduleName, + eventTypes, + eventHandlers, + cancellationToken + ); + continue; + } + + if (member is not INamedTypeSymbol typeSymbol || typeSymbol.TypeKind != TypeKind.Class) + continue; + + if ( + !typeSymbol.IsAbstract + && SymbolHelpers.ImplementsInterface(typeSymbol, eventInterface) + ) + { + eventTypes.Add( + new EventTypeRecord( + typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + moduleName + ) + ); + continue; + } + + if ( + typeSymbol.Name.EndsWith("Handler", StringComparison.Ordinal) + || typeSymbol.Name.EndsWith("Consumer", StringComparison.Ordinal) + ) + { + CollectHandledEvents(typeSymbol, eventInterface, moduleName, eventHandlers); + } + } + } + + private static void CollectHandledEvents( + INamedTypeSymbol handlerType, + INamedTypeSymbol eventInterface, + string moduleName, + List eventHandlers + ) + { + foreach (var member in handlerType.GetMembers()) + { + if ( + member is not IMethodSymbol method + || method.DeclaredAccessibility != Accessibility.Public + || method.IsStatic + || method.Parameters.Length == 0 + || Array.IndexOf(HandlerMethodNames, method.Name) < 0 + ) + continue; + + if ( + method.Parameters[0].Type is INamedTypeSymbol eventType + && SymbolHelpers.ImplementsInterface(eventType, eventInterface) + ) + { + eventHandlers.Add( + new EventHandlerRecord( + eventType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + moduleName + ) + ); + } + } + } +} diff --git a/framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs b/framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs index c1888640..24b869e5 100644 --- a/framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs +++ b/framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs @@ -110,6 +110,13 @@ internal readonly record struct ModuleDependencyRecord( string ContractsAssemblyName ); +internal readonly record struct EventTypeRecord(string FullyQualifiedName, string ModuleName); + +internal readonly record struct EventHandlerRecord( + string EventFullyQualifiedName, + string ModuleName +); + internal readonly record struct IllegalModuleReferenceRecord( string ReferencingModuleName, string ReferencingAssemblyName, diff --git a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs index 45a42e41..0f1bd450 100644 --- a/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs +++ b/framework/SimpleModule.Generator/Discovery/SymbolDiscovery.cs @@ -36,6 +36,19 @@ CancellationToken cancellationToken contractsAssemblies.Add(asm); } + // The referenced SimpleModule.Core version anchors the default framework + // compatibility range in emitted module manifests. + var coreAssemblyVersion = ""; + foreach (var identity in compilation.ReferencedAssemblyNames) + { + if (string.Equals(identity.Name, "SimpleModule.Core", StringComparison.Ordinal)) + { + coreAssemblyVersion = + $"{identity.Version.Major}.{identity.Version.Minor}.{identity.Version.Build}"; + break; + } + } + var modules = new List(); foreach (var assemblySymbol in refAssemblies) @@ -234,6 +247,21 @@ CancellationToken cancellationToken knowledgeSources ); + // Step 3i: Domain events published (IEvent implementors) and consumed + // (Wolverine-convention handler first parameters) per module + var eventTypes = new List(); + var eventHandlers = new List(); + EventFinder.Discover( + modules, + moduleSymbols, + contractsAssemblySymbols, + contractsAssemblyMap, + s, + eventTypes, + eventHandlers, + cancellationToken + ); + // Step 4: Detect dependencies and illegal references var dependencies = new List(); var illegalReferences = new List(); @@ -264,10 +292,13 @@ CancellationToken cancellationToken agentToolProviders, knowledgeSources, formRequests, + eventTypes, + eventHandlers, contractsAssemblyMap, s.HasAgentsAssembly, s.HasRagAssembly, - hostAssemblyName + hostAssemblyName, + coreAssemblyVersion ); } } diff --git a/tests/SimpleModule.Generator.Tests/EventDiscoveryTests.cs b/tests/SimpleModule.Generator.Tests/EventDiscoveryTests.cs new file mode 100644 index 00000000..0e50ea64 --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/EventDiscoveryTests.cs @@ -0,0 +1,80 @@ +using FluentAssertions; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class EventDiscoveryTests +{ + private const string ModuleWithEventsSource = """ + using System.Threading.Tasks; + using SimpleModule.Core; + using SimpleModule.Core.Events; + + namespace TestApp + { + [Module("Flags", RoutePrefix = "/api/flags")] + public class FlagsModule : IModule { } + + public sealed record FlagToggled(string Name) : DomainEvent; + + public sealed record OtherThing(string Id) : DomainEvent; + + public class OtherThingHandler + { + public Task Handle(OtherThing evt) => Task.CompletedTask; + } + + public class NotAnEventHandler + { + public Task Handle(string notAnEvent) => Task.CompletedTask; + } + + public class IrrelevantlyNamedClass + { + public Task Handle(FlagToggled evt) => Task.CompletedTask; + } + } + """; + + [Fact] + public void Discovers_DomainEventTypes_AsPublished() + { + var compilation = GeneratorTestHelper.CreateCompilation(ModuleWithEventsSource); + + var data = SymbolDiscovery.Extract(compilation, CancellationToken.None); + + data.EventTypes.Should() + .Contain(e => + e.FullyQualifiedName == "global::TestApp.FlagToggled" && e.ModuleName == "Flags" + ); + data.EventTypes.Should() + .Contain(e => + e.FullyQualifiedName == "global::TestApp.OtherThing" && e.ModuleName == "Flags" + ); + } + + [Fact] + public void Discovers_ConventionalHandlers_AsConsumed() + { + var compilation = GeneratorTestHelper.CreateCompilation(ModuleWithEventsSource); + + var data = SymbolDiscovery.Extract(compilation, CancellationToken.None); + + data.EventHandlers.Should() + .ContainSingle(h => + h.EventFullyQualifiedName == "global::TestApp.OtherThing" && h.ModuleName == "Flags" + ); + } + + [Fact] + public void Ignores_HandlersWithNonEventFirstParameter_AndNonConventionalNames() + { + var compilation = GeneratorTestHelper.CreateCompilation(ModuleWithEventsSource); + + var data = SymbolDiscovery.Extract(compilation, CancellationToken.None); + + data.EventHandlers.Should() + .NotContain(h => h.EventFullyQualifiedName == "global::TestApp.FlagToggled"); + data.EventHandlers.Should().HaveCount(1); + } +} diff --git a/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs b/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs index d016558c..4d6d0b53 100644 --- a/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs +++ b/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs @@ -248,10 +248,13 @@ public void SortModules_WithDependencies_ReordersByDependency() ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty, ImmutableArray.Empty, false, false, - "SimpleModule.Host" + "SimpleModule.Host", + "" ); var result = TopologicalSort.SortModules(data); @@ -325,10 +328,13 @@ public void SortModules_WithCycle_ReturnsOriginalOrder() ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty, ImmutableArray.Empty, false, false, - "SimpleModule.Host" + "SimpleModule.Host", + "" ); var result = TopologicalSort.SortModules(data); @@ -418,10 +424,13 @@ public void SortModules_NoDependencies_PreservesOriginalOrder() ImmutableArray.Empty, ImmutableArray.Empty, ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty, ImmutableArray.Empty, false, false, - "SimpleModule.Host" + "SimpleModule.Host", + "" ); var result = TopologicalSort.SortModules(data); From caf2b3ba6062bf097a5d63deddca32781e1004dc Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 10:52:23 +0200 Subject: [PATCH 03/31] feat(generator): emit module manifest attribute for module-kind projects --- .../SimpleModule.Core/ModuleAttribute.cs | 3 + .../Discovery/DiscoveryDataBuilder.cs | 2 + .../Discovery/Finders/ModuleFinder.cs | 14 ++ .../Discovery/Records/ModuleRecords.cs | 6 + .../Discovery/Records/WorkingTypes.cs | 2 + .../Emitters/ModuleManifestEmitter.cs | 209 ++++++++++++++++++ .../ModuleDiscovererGenerator.cs | 33 ++- .../Helpers/GeneratorTestHelper.cs | 20 ++ .../TestAnalyzerConfigOptionsProvider.cs | 37 ++++ .../ModuleManifestEmitterTests.cs | 126 +++++++++++ .../TopologicalSortTests.cs | 14 ++ 11 files changed, 464 insertions(+), 2 deletions(-) create mode 100644 framework/SimpleModule.Generator/Emitters/ModuleManifestEmitter.cs create mode 100644 tests/SimpleModule.Generator.Tests/Helpers/TestAnalyzerConfigOptionsProvider.cs create mode 100644 tests/SimpleModule.Generator.Tests/ModuleManifestEmitterTests.cs diff --git a/framework/SimpleModule.Core/ModuleAttribute.cs b/framework/SimpleModule.Core/ModuleAttribute.cs index f545f403..1fe0f76f 100644 --- a/framework/SimpleModule.Core/ModuleAttribute.cs +++ b/framework/SimpleModule.Core/ModuleAttribute.cs @@ -10,6 +10,9 @@ public sealed class ModuleAttribute : Attribute public string RoutePrefix { get; set; } = ""; public string ViewPrefix { get; set; } = ""; + /// Human-readable module name for UIs and the module manifest. Defaults to . + public string DisplayName { get; set; } = ""; + public ModuleAttribute(string name, string version = "1.0.0") { Name = name; diff --git a/framework/SimpleModule.Generator/Discovery/DiscoveryDataBuilder.cs b/framework/SimpleModule.Generator/Discovery/DiscoveryDataBuilder.cs index c032cc6b..714d3d2b 100644 --- a/framework/SimpleModule.Generator/Discovery/DiscoveryDataBuilder.cs +++ b/framework/SimpleModule.Generator/Discovery/DiscoveryDataBuilder.cs @@ -56,6 +56,8 @@ string coreAssemblyVersion m.HasConfigureRateLimits, m.RoutePrefix, m.ViewPrefix, + m.DisplayName, + m.Version, m.Endpoints.Select(e => new EndpointInfoRecord( e.FullyQualifiedName, e.RequiredPermissions.ToImmutableArray(), diff --git a/framework/SimpleModule.Generator/Discovery/Finders/ModuleFinder.cs b/framework/SimpleModule.Generator/Discovery/Finders/ModuleFinder.cs index 82d93654..2ac60deb 100644 --- a/framework/SimpleModule.Generator/Discovery/Finders/ModuleFinder.cs +++ b/framework/SimpleModule.Generator/Discovery/Finders/ModuleFinder.cs @@ -37,8 +37,13 @@ CancellationToken cancellationToken attr.ConstructorArguments.Length > 0 ? attr.ConstructorArguments[0].Value as string ?? "" : ""; + var moduleVersion = + attr.ConstructorArguments.Length > 1 + ? attr.ConstructorArguments[1].Value as string ?? "" + : ""; var routePrefix = ""; var viewPrefix = ""; + var displayName = ""; foreach (var namedArg in attr.NamedArguments) { if ( @@ -55,6 +60,13 @@ CancellationToken cancellationToken { viewPrefix = vPrefix; } + else if ( + namedArg.Key == "DisplayName" + && namedArg.Value.Value is string dName + ) + { + displayName = dName; + } } modules.Add( @@ -122,6 +134,8 @@ symbols.ModuleSettings is not null ), RoutePrefix = routePrefix, ViewPrefix = viewPrefix, + DisplayName = displayName, + Version = moduleVersion, AssemblyName = typeSymbol.ContainingAssembly.Name, Location = SymbolHelpers.GetSourceLocation(typeSymbol), } diff --git a/framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs b/framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs index 24b869e5..31898b9c 100644 --- a/framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs +++ b/framework/SimpleModule.Generator/Discovery/Records/ModuleRecords.cs @@ -18,6 +18,8 @@ internal readonly record struct ModuleInfoRecord( bool HasConfigureRateLimits, string RoutePrefix, string ViewPrefix, + string DisplayName, + string Version, ImmutableArray Endpoints, ImmutableArray Views, SourceLocationRecord? Location @@ -39,6 +41,8 @@ public bool Equals(ModuleInfoRecord other) && HasConfigureRateLimits == other.HasConfigureRateLimits && RoutePrefix == other.RoutePrefix && ViewPrefix == other.ViewPrefix + && DisplayName == other.DisplayName + && Version == other.Version && Endpoints.SequenceEqual(other.Endpoints) && Views.SequenceEqual(other.Views) && Location == other.Location; @@ -61,6 +65,8 @@ public override int GetHashCode() hash = HashHelper.Combine(hash, HasConfigureRateLimits.GetHashCode()); hash = HashHelper.Combine(hash, (RoutePrefix ?? "").GetHashCode()); hash = HashHelper.Combine(hash, (ViewPrefix ?? "").GetHashCode()); + hash = HashHelper.Combine(hash, (DisplayName ?? "").GetHashCode()); + hash = HashHelper.Combine(hash, (Version ?? "").GetHashCode()); hash = HashHelper.HashArray(hash, Endpoints); hash = HashHelper.HashArray(hash, Views); hash = HashHelper.Combine(hash, Location.GetHashCode()); diff --git a/framework/SimpleModule.Generator/Discovery/Records/WorkingTypes.cs b/framework/SimpleModule.Generator/Discovery/Records/WorkingTypes.cs index f70ca313..d93f8c14 100644 --- a/framework/SimpleModule.Generator/Discovery/Records/WorkingTypes.cs +++ b/framework/SimpleModule.Generator/Discovery/Records/WorkingTypes.cs @@ -21,6 +21,8 @@ internal sealed class ModuleInfo public bool HasConfigureRateLimits { get; set; } public string RoutePrefix { get; set; } = ""; public string ViewPrefix { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public string Version { get; set; } = ""; public List Endpoints { get; set; } = new(); public List Views { get; set; } = new(); public SourceLocationRecord? Location { get; set; } diff --git a/framework/SimpleModule.Generator/Emitters/ModuleManifestEmitter.cs b/framework/SimpleModule.Generator/Emitters/ModuleManifestEmitter.cs new file mode 100644 index 00000000..4b5d90e7 --- /dev/null +++ b/framework/SimpleModule.Generator/Emitters/ModuleManifestEmitter.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SimpleModule.Generator; + +/// +/// Emits the module manifest as an assembly-level +/// [assembly: ModuleManifest("{json}")] attribute into module-kind +/// compilations (projects built with SimpleModuleProjectKind=Module). +/// Source generators cannot add embedded resources, so the attribute is the +/// closest in-assembly equivalent — readable at runtime via reflection and by +/// tooling via System.Reflection.Metadata without loading the assembly. +/// +internal static class ModuleManifestEmitter +{ + internal const int SchemaVersion = 1; + + internal static void Emit( + SourceProductionContext context, + DiscoveryData data, + string frameworkCompatOverride + ) + { + // Only the module declared in THIS compilation gets a manifest; modules + // visible through references belong to other assemblies. The attribute + // allows one manifest per assembly, so a multi-module assembly (not a + // supported packaging shape) gets a manifest for its first module. + var module = data.Modules.FirstOrDefault(m => m.AssemblyName == data.HostAssemblyName); + if (module.ModuleName is null or "") + return; + + var json = BuildManifestJson(module, data, frameworkCompatOverride); + + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine( + $"[assembly: global::SimpleModule.Core.Modules.ModuleManifest({ToCSharpLiteral(json)})]" + ); + + context.AddSource("ModuleManifest.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8)); + } + + private static string BuildManifestJson( + ModuleInfoRecord module, + DiscoveryData data, + string frameworkCompatOverride + ) + { + var permissions = data + .PermissionClasses.Where(p => p.ModuleName == module.ModuleName) + .SelectMany(p => p.Fields) + .Where(f => f.IsConstString && !string.IsNullOrEmpty(f.Value)) + .Select(f => f.Value) + .Distinct(StringComparer.Ordinal) + .OrderBy(v => v, StringComparer.Ordinal) + .ToList(); + + var pages = module + .Views.Select(v => v.Page) + .Where(p => !string.IsNullOrEmpty(p)) + .Distinct(StringComparer.Ordinal) + .OrderBy(p => p, StringComparer.Ordinal) + .ToList(); + + var eventsPublished = data + .EventTypes.Where(e => e.ModuleName == module.ModuleName) + .Select(e => StripGlobal(e.FullyQualifiedName)) + .Distinct(StringComparer.Ordinal) + .OrderBy(e => e, StringComparer.Ordinal) + .ToList(); + + var eventsConsumed = data + .EventHandlers.Where(h => h.ModuleName == module.ModuleName) + .Select(h => StripGlobal(h.EventFullyQualifiedName)) + .Distinct(StringComparer.Ordinal) + .OrderBy(e => e, StringComparer.Ordinal) + .ToList(); + + var hasDbContext = data.DbContexts.Any(c => c.ModuleName == module.ModuleName); + + var frontendEntry = + module.Views.Length > 0 + ? $"_content/{module.AssemblyName}/{module.AssemblyName}.pages.js" + : null; + + var displayName = string.IsNullOrEmpty(module.DisplayName) + ? module.ModuleName + : module.DisplayName; + + var frameworkCompat = !string.IsNullOrEmpty(frameworkCompatOverride) + ? frameworkCompatOverride + : DefaultFrameworkCompat(data.CoreAssemblyVersion); + + var sb = new StringBuilder(); + sb.Append('{'); + AppendProperty(sb, "schemaVersion", SchemaVersion); + AppendProperty(sb, "id", module.AssemblyName); + AppendProperty(sb, "name", module.ModuleName); + AppendProperty(sb, "displayName", displayName); + AppendProperty(sb, "version", module.Version); + AppendProperty(sb, "frameworkCompat", frameworkCompat); + AppendProperty(sb, "routePrefix", module.RoutePrefix); + AppendProperty(sb, "viewPrefix", module.ViewPrefix); + AppendProperty(sb, "schema", module.ModuleName); + AppendArrayProperty(sb, "permissions", permissions); + if (frontendEntry is null) + { + sb.Append("\"frontendEntry\":null,"); + } + else + { + AppendProperty(sb, "frontendEntry", frontendEntry); + } + AppendArrayProperty(sb, "pages", pages); + AppendArrayProperty(sb, "eventsPublished", eventsPublished); + AppendArrayProperty(sb, "eventsConsumed", eventsConsumed); + sb.Append("\"hasDbContext\":").Append(hasDbContext ? "true" : "false"); + sb.Append('}'); + return sb.ToString(); + } + + private static string DefaultFrameworkCompat(string coreVersion) + { + if (string.IsNullOrEmpty(coreVersion)) + return ""; + + var majorPart = coreVersion.Split('.')[0]; + return int.TryParse(majorPart, out var major) + ? $">={coreVersion} <{major + 1}.0.0" + : $">={coreVersion}"; + } + + private static string StripGlobal(string fullyQualifiedName) => + fullyQualifiedName.StartsWith("global::", StringComparison.Ordinal) + ? fullyQualifiedName.Substring("global::".Length) + : fullyQualifiedName; + + private static void AppendProperty(StringBuilder sb, string name, int value) => + sb.Append('"').Append(name).Append("\":").Append(value).Append(','); + + private static void AppendProperty(StringBuilder sb, string name, string value) => + sb.Append('"').Append(name).Append("\":").Append(JsonString(value)).Append(','); + + private static void AppendArrayProperty(StringBuilder sb, string name, List values) + { + sb.Append('"').Append(name).Append("\":["); + for (var i = 0; i < values.Count; i++) + { + if (i > 0) + sb.Append(','); + sb.Append(JsonString(values[i])); + } + sb.Append("],"); + } + + private static string JsonString(string value) + { + var sb = new StringBuilder(value.Length + 2); + sb.Append('"'); + foreach (var c in value) + { + switch (c) + { + case '"': + sb.Append("\\\""); + break; + case '\\': + sb.Append("\\\\"); + break; + case '\n': + sb.Append("\\n"); + break; + case '\r': + sb.Append("\\r"); + break; + case '\t': + sb.Append("\\t"); + break; + default: + if (c < 0x20) + { + sb.Append("\\u") + .Append( + ((int)c).ToString( + "x4", + System.Globalization.CultureInfo.InvariantCulture + ) + ); + } + else + { + sb.Append(c); + } + break; + } + } + sb.Append('"'); + return sb.ToString(); + } + + /// + /// Renders the JSON as a verbatim C# string literal for the attribute argument. + /// + private static string ToCSharpLiteral(string json) => "@\"" + json.Replace("\"", "\"\"") + "\""; +} diff --git a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs index 8bb49255..148145d5 100644 --- a/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs +++ b/framework/SimpleModule.Generator/ModuleDiscovererGenerator.cs @@ -39,13 +39,42 @@ public void Initialize(IncrementalGeneratorInitializationContext context) SymbolDiscovery.Extract(compilation, cancellationToken) ); + // MSBuild properties surfaced via : + // SimpleModuleProjectKind = "Module" switches the generator from host + // emission (AddModules, endpoint maps, ...) to emitting only the module + // manifest attribute into the module's own assembly. + // SimpleModuleFrameworkCompat overrides the manifest's compat range. + var optionsProvider = context.AnalyzerConfigOptionsProvider.Select( + static (provider, _) => + { + provider.GlobalOptions.TryGetValue( + "build_property.SimpleModuleProjectKind", + out var kind + ); + provider.GlobalOptions.TryGetValue( + "build_property.SimpleModuleFrameworkCompat", + out var compat + ); + return (Kind: kind ?? "", Compat: compat ?? ""); + } + ); + context.RegisterSourceOutput( - dataProvider, - static (spc, data) => + dataProvider.Combine(optionsProvider), + static (spc, pair) => { + var (data, options) = pair; if (data.Modules.Length == 0) return; + if ( + string.Equals(options.Kind, "Module", System.StringComparison.OrdinalIgnoreCase) + ) + { + ModuleManifestEmitter.Emit(spc, data, options.Compat); + return; + } + foreach (var emitter in Emitters) { emitter.Emit(spc, data); diff --git a/tests/SimpleModule.Generator.Tests/Helpers/GeneratorTestHelper.cs b/tests/SimpleModule.Generator.Tests/Helpers/GeneratorTestHelper.cs index 581293c8..3cf1d844 100644 --- a/tests/SimpleModule.Generator.Tests/Helpers/GeneratorTestHelper.cs +++ b/tests/SimpleModule.Generator.Tests/Helpers/GeneratorTestHelper.cs @@ -164,6 +164,26 @@ public static GeneratorDriverRunResult RunGenerator(CSharpCompilation compilatio return driver.GetRunResult(); } + /// + /// Runs the generator with MSBuild build properties visible via analyzer config + /// (e.g. build_property.SimpleModuleProjectKind). + /// + public static GeneratorDriverRunResult RunGenerator( + CSharpCompilation compilation, + Dictionary buildProperties + ) + { + var generator = new ModuleDiscovererGenerator(); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [generator.AsSourceGenerator()], + optionsProvider: new TestAnalyzerConfigOptionsProvider(buildProperties) + ); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _); + + return driver.GetRunResult(); + } + public static ( GeneratorDriverRunResult Result, ImmutableArray Diagnostics diff --git a/tests/SimpleModule.Generator.Tests/Helpers/TestAnalyzerConfigOptionsProvider.cs b/tests/SimpleModule.Generator.Tests/Helpers/TestAnalyzerConfigOptionsProvider.cs new file mode 100644 index 00000000..233f83c7 --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/Helpers/TestAnalyzerConfigOptionsProvider.cs @@ -0,0 +1,37 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace SimpleModule.Generator.Tests.Helpers; + +/// +/// Exposes a fixed set of global analyzer-config options (build properties) to +/// generators under test, mimicking MSBuild's CompilerVisibleProperty plumbing. +/// +public sealed class TestAnalyzerConfigOptionsProvider(Dictionary globalOptions) + : AnalyzerConfigOptionsProvider +{ + public override AnalyzerConfigOptions GlobalOptions { get; } = + new TestAnalyzerConfigOptions(globalOptions); + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => + new TestAnalyzerConfigOptions([]); + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => + new TestAnalyzerConfigOptions([]); + + private sealed class TestAnalyzerConfigOptions(Dictionary options) + : AnalyzerConfigOptions + { + public override bool TryGetValue(string key, out string value) + { + if (options.TryGetValue(key, out var found)) + { + value = found; + return true; + } + + value = ""; + return false; + } + } +} diff --git a/tests/SimpleModule.Generator.Tests/ModuleManifestEmitterTests.cs b/tests/SimpleModule.Generator.Tests/ModuleManifestEmitterTests.cs new file mode 100644 index 00000000..91b31daa --- /dev/null +++ b/tests/SimpleModule.Generator.Tests/ModuleManifestEmitterTests.cs @@ -0,0 +1,126 @@ +using FluentAssertions; +using Microsoft.CodeAnalysis; +using SimpleModule.Generator.Tests.Helpers; + +namespace SimpleModule.Generator.Tests; + +public class ModuleManifestEmitterTests +{ + private const string ModuleSource = """ + using Microsoft.AspNetCore.Builder; + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Routing; + using System.Threading.Tasks; + using SimpleModule.Core; + using SimpleModule.Core.Authorization; + using SimpleModule.Core.Events; + + namespace TestApp + { + [Module("Flags", RoutePrefix = "/api/flags", ViewPrefix = "/flags", DisplayName = "Feature Flags")] + public class FlagsModule : IModule { } + + public sealed class FlagsPermissions : IModulePermissions + { + public const string View = "Flags.View"; + public const string Manage = "Flags.Manage"; + } + + public sealed record FlagToggled(string Name) : DomainEvent; + + public sealed record ExternalThing(string Id) : DomainEvent; + + public class ExternalThingHandler + { + public Task Handle(ExternalThing evt) => Task.CompletedTask; + } + } + + namespace TestApp.Pages + { + public class ManageEndpoint : IViewEndpoint + { + public void Map(IEndpointRouteBuilder app) + { + app.MapGet("/manage", () => Results.Ok()); + } + } + } + """; + + private static readonly Dictionary ModuleKindProperties = new() + { + ["build_property.SimpleModuleProjectKind"] = "Module", + }; + + [Fact] + public void ModuleKind_EmitsManifestAttribute_WithExpectedFields() + { + var compilation = GeneratorTestHelper.CreateCompilation(ModuleSource); + + var result = GeneratorTestHelper.RunGenerator(compilation, ModuleKindProperties); + + var manifest = GetGeneratedSource(result, "ModuleManifest.g.cs"); + manifest.Should().Contain("assembly: global::SimpleModule.Core.Modules.ModuleManifest"); + manifest.Should().Contain("schemaVersion"); + manifest.Should().Contain("TestAssembly"); + manifest.Should().Contain("Flags"); + manifest.Should().Contain("Feature Flags"); + manifest.Should().Contain("/api/flags"); + manifest.Should().Contain("Flags.View"); + manifest.Should().Contain("Flags.Manage"); + manifest.Should().Contain("_content/TestAssembly/TestAssembly.pages.js"); + manifest.Should().Contain("TestApp.FlagToggled"); + manifest.Should().Contain("TestApp.ExternalThing"); + } + + [Fact] + public void ModuleKind_DoesNotEmitHostArtifacts() + { + var compilation = GeneratorTestHelper.CreateCompilation(ModuleSource); + + var result = GeneratorTestHelper.RunGenerator(compilation, ModuleKindProperties); + + var fileNames = result.Results[0].GeneratedSources.Select(s => s.HintName).ToList(); + fileNames.Should().NotContain("ModuleExtensions.g.cs"); + fileNames.Should().NotContain("HostingExtensions.g.cs"); + fileNames.Should().NotContain("PageRegistry.g.cs"); + } + + [Fact] + public void HostKind_DoesNotEmitManifest_AndKeepsClassicArtifacts() + { + var compilation = GeneratorTestHelper.CreateCompilation(ModuleSource); + + var result = GeneratorTestHelper.RunGenerator(compilation); + + var fileNames = result.Results[0].GeneratedSources.Select(s => s.HintName).ToList(); + fileNames.Should().NotContain("ModuleManifest.g.cs"); + fileNames.Should().Contain("ModuleExtensions.g.cs"); + } + + [Fact] + public void FrameworkCompatOverride_IsEmittedVerbatim() + { + var compilation = GeneratorTestHelper.CreateCompilation(ModuleSource); + + var result = GeneratorTestHelper.RunGenerator( + compilation, + new Dictionary + { + ["build_property.SimpleModuleProjectKind"] = "Module", + ["build_property.SimpleModuleFrameworkCompat"] = ">=9.9.9 <10.0.0", + } + ); + + var manifest = GetGeneratedSource(result, "ModuleManifest.g.cs"); + manifest.Should().Contain(">=9.9.9 <10.0.0"); + } + + private static string GetGeneratedSource(GeneratorDriverRunResult result, string hintName) + { + var source = result.Results[0].GeneratedSources.FirstOrDefault(s => s.HintName == hintName); + source.SourceText.Should().NotBeNull($"expected generated source '{hintName}'"); + return source.SourceText.ToString(); + } +} diff --git a/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs b/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs index 4d6d0b53..38832f45 100644 --- a/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs +++ b/tests/SimpleModule.Generator.Tests/TopologicalSortTests.cs @@ -208,6 +208,8 @@ public void SortModules_WithDependencies_ReordersByDependency() false, "", "", + "", + "", ImmutableArray.Empty, ImmutableArray.Empty, null @@ -227,6 +229,8 @@ public void SortModules_WithDependencies_ReordersByDependency() false, "", "", + "", + "", ImmutableArray.Empty, ImmutableArray.Empty, null @@ -285,6 +289,8 @@ public void SortModules_WithCycle_ReturnsOriginalOrder() false, "", "", + "", + "", ImmutableArray.Empty, ImmutableArray.Empty, null @@ -304,6 +310,8 @@ public void SortModules_WithCycle_ReturnsOriginalOrder() false, "", "", + "", + "", ImmutableArray.Empty, ImmutableArray.Empty, null @@ -365,6 +373,8 @@ public void SortModules_NoDependencies_PreservesOriginalOrder() false, "", "", + "", + "", ImmutableArray.Empty, ImmutableArray.Empty, null @@ -384,6 +394,8 @@ public void SortModules_NoDependencies_PreservesOriginalOrder() false, "", "", + "", + "", ImmutableArray.Empty, ImmutableArray.Empty, null @@ -403,6 +415,8 @@ public void SortModules_NoDependencies_PreservesOriginalOrder() false, "", "", + "", + "", ImmutableArray.Empty, ImmutableArray.Empty, null From e832463c24c7b48c25a52fe57ecb61d63d0f4e87 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 10:54:02 +0200 Subject: [PATCH 04/31] build(modules): run source generator in module-kind on module projects --- modules/Directory.Build.props | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/modules/Directory.Build.props b/modules/Directory.Build.props index d5a5b735..da5a8b75 100644 --- a/modules/Directory.Build.props +++ b/modules/Directory.Build.props @@ -9,4 +9,27 @@ + + + Module + + + + + + + + From 8c4d5c44390b36bc930815f44c1bb7ff3a17b9d8 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 10:57:13 +0200 Subject: [PATCH 05/31] feat(hosting): module manifest registry and frontend asset map injection --- .../Modules/IModuleManifestRegistry.cs | 15 +++++++ .../Inertia/HtmlFileInertiaPageRenderer.cs | 30 ++++++++++++- .../Modules/ModuleManifestRegistry.cs | 38 ++++++++++++++++ .../SimpleModuleHostExtensions.cs | 10 +++++ .../Inertia/ModuleAssetMapTests.cs | 44 +++++++++++++++++++ 5 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 framework/SimpleModule.Core/Modules/IModuleManifestRegistry.cs create mode 100644 framework/SimpleModule.Hosting/Modules/ModuleManifestRegistry.cs create mode 100644 tests/SimpleModule.Core.Tests/Inertia/ModuleAssetMapTests.cs diff --git a/framework/SimpleModule.Core/Modules/IModuleManifestRegistry.cs b/framework/SimpleModule.Core/Modules/IModuleManifestRegistry.cs new file mode 100644 index 00000000..f93b7664 --- /dev/null +++ b/framework/SimpleModule.Core/Modules/IModuleManifestRegistry.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace SimpleModule.Core.Modules; + +/// +/// Runtime access to the compile-time manifests of all loaded modules. +/// Modules compiled before manifest emission existed simply have no entry. +/// +public interface IModuleManifestRegistry +{ + IReadOnlyList Manifests { get; } + + /// Returns the manifest for the given module name, or null when absent. + ModuleManifest? Get(string moduleName); +} diff --git a/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs b/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs index 1b33770c..6c5d7580 100644 --- a/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs +++ b/framework/SimpleModule.Hosting/Inertia/HtmlFileInertiaPageRenderer.cs @@ -1,9 +1,11 @@ using System.Text; +using System.Text.Json; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using SimpleModule.Core.Inertia; +using SimpleModule.Core.Modules; using SimpleModule.Core.Security; using SimpleModule.DevTools; @@ -21,9 +23,15 @@ public sealed class HtmlFileInertiaPageRenderer : IInertiaPageRenderer private readonly string _beforePlaceholderViteDev; private readonly string _afterPlaceholderViteDev; private readonly bool _isDevelopment; + private readonly string? _moduleAssetsJson; - public HtmlFileInertiaPageRenderer(IWebHostEnvironment env) + public HtmlFileInertiaPageRenderer( + IWebHostEnvironment env, + IModuleManifestRegistry manifestRegistry + ) { + _moduleAssetsJson = BuildModuleAssetsJson(manifestRegistry); + var path = Path.Combine(env.WebRootPath, "index.html"); var html = File.ReadAllText(path); @@ -82,10 +90,18 @@ public Task RenderPageAsync(HttpContext httpContext, string pageJson) ? "" : ""; + // Module bundle map for the client-side page resolver — lets it import the + // exact asset path from each module's manifest instead of probing + // /_content/ naming conventions. type="application/json" is inert data. + var moduleAssetsScript = _moduleAssetsJson is null + ? "" + : $""; + httpContext.Response.ContentType = "text/html; charset=utf-8"; return httpContext.Response.WriteAsync( string.Concat( before.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal), + moduleAssetsScript, $"", devScript, after.Replace(NoncePlaceholder, nonce, StringComparison.Ordinal) @@ -93,6 +109,18 @@ public Task RenderPageAsync(HttpContext httpContext, string pageJson) ); } + private static string? BuildModuleAssetsJson(IModuleManifestRegistry manifestRegistry) + { + var assets = new SortedDictionary(StringComparer.Ordinal); + foreach (var manifest in manifestRegistry.Manifests) + { + if (!string.IsNullOrEmpty(manifest.FrontendEntry)) + assets[manifest.Name] = manifest.FrontendEntry; + } + + return assets.Count == 0 ? null : JsonSerializer.Serialize(assets); + } + private static string BuildModuleCssLinks(IWebHostEnvironment env, string version) { var contents = env.WebRootFileProvider.GetDirectoryContents("_content"); diff --git a/framework/SimpleModule.Hosting/Modules/ModuleManifestRegistry.cs b/framework/SimpleModule.Hosting/Modules/ModuleManifestRegistry.cs new file mode 100644 index 00000000..ae6b2fe2 --- /dev/null +++ b/framework/SimpleModule.Hosting/Modules/ModuleManifestRegistry.cs @@ -0,0 +1,38 @@ +using SimpleModule.Core; +using SimpleModule.Core.Modules; + +namespace SimpleModule.Hosting.Modules; + +/// +/// Builds the manifest registry from the registered +/// instances by reading each module assembly's . +/// +public sealed class ModuleManifestRegistry : IModuleManifestRegistry +{ + private readonly Dictionary _byName; + + public ModuleManifestRegistry(IEnumerable modules) + { + ArgumentNullException.ThrowIfNull(modules); + + _byName = new Dictionary(StringComparer.Ordinal); + var seenAssemblies = new HashSet(StringComparer.Ordinal); + foreach (var module in modules) + { + var assembly = module.GetType().Assembly; + if (!seenAssemblies.Add(assembly.FullName ?? assembly.GetName().Name ?? "")) + continue; + + var manifest = ModuleManifestReader.TryRead(assembly); + if (manifest is not null && !_byName.ContainsKey(manifest.Name)) + _byName[manifest.Name] = manifest; + } + + Manifests = [.. _byName.Values]; + } + + public IReadOnlyList Manifests { get; } + + public ModuleManifest? Get(string moduleName) => + _byName.TryGetValue(moduleName, out var manifest) ? manifest : null; +} diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index c4c36e65..b83ddb7e 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Constants; using SimpleModule.Core.Exceptions; @@ -17,6 +18,7 @@ using SimpleModule.Core.Inertia; using SimpleModule.Core.Maintenance; using SimpleModule.Core.Menu; +using SimpleModule.Core.Modules; using SimpleModule.Core.RateLimiting; using SimpleModule.Core.Security; using SimpleModule.Database; @@ -27,6 +29,7 @@ using SimpleModule.Hosting.Inertia; using SimpleModule.Hosting.Maintenance; using SimpleModule.Hosting.Middleware; +using SimpleModule.Hosting.Modules; using SimpleModule.Hosting.RateLimiting; using Wolverine; using ZiggyCreatures.Caching.Fusion; @@ -113,6 +116,13 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( builder.Services.AddSingleton(); + // Compile-time module manifests, read from each module assembly's + // [assembly: ModuleManifest] attribute. Resolved lazily so registration + // order relative to the generated AddModules() does not matter. + builder.Services.AddSingleton(sp => new ModuleManifestRegistry( + sp.GetServices() + )); + // Unified caching abstraction (IFusionCache) shared across all modules. // Stampede-safe GetOrSetAsync built in; five-minute default entry duration. builder diff --git a/tests/SimpleModule.Core.Tests/Inertia/ModuleAssetMapTests.cs b/tests/SimpleModule.Core.Tests/Inertia/ModuleAssetMapTests.cs new file mode 100644 index 00000000..79760120 --- /dev/null +++ b/tests/SimpleModule.Core.Tests/Inertia/ModuleAssetMapTests.cs @@ -0,0 +1,44 @@ +using System.Net; +using FluentAssertions; +using SimpleModule.Tests.Shared.Fixtures; + +namespace SimpleModule.Core.Tests.Inertia; + +[Collection(TestCollections.Integration)] +public class ModuleAssetMapTests +{ + private readonly SimpleModuleWebApplicationFactory _factory; + + public ModuleAssetMapTests(SimpleModuleWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task HtmlShell_ContainsModuleAssetMap() + { + using var client = _factory.CreateClient(); + var response = await client.GetAsync("/"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var html = await response.Content.ReadAsStringAsync(); + html.Should().Contain("id=\"sm-module-assets\""); + html.Should() + .Contain("_content/SimpleModule.FeatureFlags/SimpleModule.FeatureFlags.pages.js"); + } + + [Fact] + public async Task AssetMapScript_IsJsonNotExecutable() + { + using var client = _factory.CreateClient(); + var response = await client.GetAsync("/"); + + var html = await response.Content.ReadAsStringAsync(); + var scriptIndex = html.IndexOf("id=\"sm-module-assets\"", StringComparison.Ordinal); + scriptIndex.Should().BeGreaterThan(0); + + var tagStart = html.LastIndexOf("', scriptIndex)]; + tag.Should().Contain("type=\"application/json\""); + } +} From d1d0d9d1999966630e12692e8b7ab4ed01ea37da Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 10:58:05 +0200 Subject: [PATCH 06/31] feat(client): resolve module bundles via sm-module-assets manifest map --- .../SimpleModule.Client/src/resolve-page.ts | 53 ++++++++++++++++--- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/SimpleModule.Client/src/resolve-page.ts b/packages/SimpleModule.Client/src/resolve-page.ts index 753208be..3ea776f6 100644 --- a/packages/SimpleModule.Client/src/resolve-page.ts +++ b/packages/SimpleModule.Client/src/resolve-page.ts @@ -4,6 +4,22 @@ // module. We only ever store a name that actually resolved. const resolvedAssemblies = new Map(); +// Module → bundle path map injected into the HTML shell by the host from each +// module's compile-time manifest ( +``` + +The client-side page resolver imports the exact path from this map. Modules +without a manifest (built before manifest emission existed) fall back to the +legacy convention probe: `/_content/SimpleModule.{Module}/…` then +`/_content/{Module}/…`. + +## Version compatibility rules + +`frameworkCompat` is a SemVer range over the `SimpleModule.Core` version: + +- **Default:** derived at compile time from the referenced `SimpleModule.Core` + assembly: `>={version} <{nextMajor}.0.0`. +- **Override:** set the MSBuild property explicitly when you have verified a + wider or narrower range: + + ```xml + + >=0.0.38 <1.0.0 + + ``` + +- Installation tooling (`sm add`, planned) checks the host's framework version + against this range **before** installing and refuses incompatible modules + (override with `--force` at your own risk). + +::: tip Pre-1.0 caveat +While the framework is on `0.x`, the default range `>=0.0.N <1.0.0` is +optimistic — SemVer reserves the right to break between 0.x minors. The range +semantics tighten when the framework reaches 1.0. Pin a narrower override if +your module depends on unstable surface. +::: + +## Reading manifests programmatically + +```csharp +using SimpleModule.Core.Modules; + +// At runtime (host side) — all loaded modules: +var registry = serviceProvider.GetRequiredService(); +foreach (var manifest in registry.Manifests) +{ + Console.WriteLine($"{manifest.DisplayName} {manifest.Version} ({manifest.Id})"); +} + +// From a specific assembly: +ModuleManifest? manifest = ModuleManifestReader.TryRead(typeof(MyModule).Assembly); +``` From 2115ab1f92570ba230e04a6d46bafa8fa39421c3 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 11:11:16 +0200 Subject: [PATCH 09/31] chore: record module-packaging session 1 plan and review in tasks/todo.md --- tasks/todo.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tasks/todo.md b/tasks/todo.md index b6e7c32b..5d9a3ed3 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -77,3 +77,27 @@ Notable finds while fixing: `SaveChangesAndFlushMessagesAsync`, which per Wolverine commits the open transaction before flushing. `FakeDbContextOutbox` now mirrors that commit behavior. +# Task: Module distribution — Session 1 (package contract) + +Full plan: docs/superpowers/plans/2026-06-10-module-packaging-session1.md +Branch: worktree-module-packaging-s1 +(Previous todo — design-system consistency pass — shipped in PR #243.) + +## Plan + +- [x] Marketplace-module audit → already deleted in b2698964 (PR #145); recommend leaving deleted, harvest NuGet-client patterns from history for Session 3 `sm search` +- [x] Core: `ModuleManifest` model + `ModuleManifestAttribute` + `ModuleManifestReader` (schemaVersion gate, forward-compat parsing) +- [x] Generator: `EventFinder` — events published (IEvent implementors) / consumed (Wolverine-convention handlers) +- [x] Generator: `ModuleManifestEmitter` + `[Module(DisplayName=…)]` + `SimpleModuleProjectKind`/`SimpleModuleFrameworkCompat` build-property switches +- [x] modules/Directory.Build.props: attach generator in module-kind to module impl projects +- [x] Hosting: `IModuleManifestRegistry` + `sm-module-assets` JSON script injection in the HTML shell +- [x] Client: manifest-first bundle resolution in resolve-page.ts (convention probing kept as fallback) +- [x] Hosting: apply EF migrations for module DbContexts that bundle them +- [x] Docs: docs/site/advanced/module-packaging.md (manifest schema v1, nupkg layout, externals contract, compat rules) +- [x] Checkpoint: FeatureFlags packed → local feed → PackageReference in Host → page renders in browser (verified via Playwright, logged in as admin) + +## Review + +- All suites green: solution build 0 warnings; Core 258, Generator 213, Database 93, DevTools 35, CLI 136 (xunit v3 exe), 15 module suites all pass; `npm run check` + `validate-pages` clean. +- Deviation: manifest is an assembly-level attribute, not an embedded resource — Roslyn generators cannot emit resources. `sm pack` (Session 2) will additionally extract `module-manifest.json` into the nupkg. +- Next (Session 2): `sm pack` / `sm add` / `sm remove` / `sm list`; handle CPM (NU1008) on add; force production frontend build on pack. From 835ba022626e67af19996d57a6e9afa95b6a6a51 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 11:44:41 +0200 Subject: [PATCH 10/31] feat(cli): sm.json registry config and framework compat checker --- .../Infrastructure/FrameworkCompatChecker.cs | 193 ++++++++++++++++++ .../Infrastructure/SmConfig.cs | 59 ++++++ .../2026-06-10-module-packaging-session2.md | 102 +++++++++ .../FrameworkCompatCheckerTests.cs | 65 ++++++ tests/SimpleModule.Cli.Tests/SmConfigTests.cs | 74 +++++++ 5 files changed, 493 insertions(+) create mode 100644 cli/SimpleModule.Cli/Infrastructure/FrameworkCompatChecker.cs create mode 100644 cli/SimpleModule.Cli/Infrastructure/SmConfig.cs create mode 100644 docs/superpowers/plans/2026-06-10-module-packaging-session2.md create mode 100644 tests/SimpleModule.Cli.Tests/FrameworkCompatCheckerTests.cs create mode 100644 tests/SimpleModule.Cli.Tests/SmConfigTests.cs diff --git a/cli/SimpleModule.Cli/Infrastructure/FrameworkCompatChecker.cs b/cli/SimpleModule.Cli/Infrastructure/FrameworkCompatChecker.cs new file mode 100644 index 00000000..fc72b022 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/FrameworkCompatChecker.cs @@ -0,0 +1,193 @@ +using System.Globalization; + +namespace SimpleModule.Cli.Infrastructure; + +public readonly record struct CompatResult(bool Compatible, string Reason); + +/// +/// Evaluates a module manifest's frameworkCompat SemVer range against the +/// host's SimpleModule.Core version. Supported range grammar (what the source +/// generator emits): >=X.Y.Z[-pre] optionally followed by <A.B.C[-pre]. +/// +public static class FrameworkCompatChecker +{ + public static CompatResult Check(string range, string hostVersion) + { + if (string.IsNullOrWhiteSpace(range)) + { + return new CompatResult( + true, + "Module declares no framework compatibility range; assuming compatible." + ); + } + + if (!SemVer.TryParse(hostVersion, out var host)) + { + return new CompatResult( + false, + $"Host framework version '{hostVersion}' is not a valid semantic version." + ); + } + + SemVer? lower = null; + SemVer? upper = null; + foreach (var part in range.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + if (part.StartsWith(">=", StringComparison.Ordinal)) + { + if (!SemVer.TryParse(part[2..], out var parsed)) + { + return Unparseable(range); + } + + lower = parsed; + } + else if (part.StartsWith('<')) + { + if (!SemVer.TryParse(part[1..], out var parsed)) + { + return Unparseable(range); + } + + upper = parsed; + } + else + { + return Unparseable(range); + } + } + + if (lower is null && upper is null) + { + return Unparseable(range); + } + + if (lower is not null && host.CompareTo(lower.Value) < 0) + { + return new CompatResult( + false, + $"Host framework {hostVersion} is older than the module's minimum {lower}." + ); + } + + if (upper is not null && host.CompareTo(upper.Value) >= 0) + { + return new CompatResult( + false, + $"Host framework {hostVersion} is at or above the module's exclusive upper bound {upper}." + ); + } + + return new CompatResult(true, $"Host framework {hostVersion} satisfies '{range}'."); + } + + private static CompatResult Unparseable(string range) => + new(false, $"Could not parse framework compatibility range '{range}'."); + + private readonly record struct SemVer(int Major, int Minor, int Patch, string Prerelease) + : IComparable + { + public static bool TryParse(string input, out SemVer version) + { + version = default; + if (string.IsNullOrWhiteSpace(input)) + { + return false; + } + + var core = input; + var prerelease = ""; + var dash = input.IndexOf('-', StringComparison.Ordinal); + if (dash >= 0) + { + core = input[..dash]; + prerelease = input[(dash + 1)..]; + } + + var parts = core.Split('.'); + if (parts.Length is < 2 or > 3) + { + return false; + } + + if ( + !int.TryParse( + parts[0], + NumberStyles.None, + CultureInfo.InvariantCulture, + out var major + ) + || !int.TryParse( + parts[1], + NumberStyles.None, + CultureInfo.InvariantCulture, + out var minor + ) + ) + { + return false; + } + + var patch = 0; + if ( + parts.Length == 3 + && !int.TryParse( + parts[2], + NumberStyles.None, + CultureInfo.InvariantCulture, + out patch + ) + ) + { + return false; + } + + version = new SemVer(major, minor, patch, prerelease); + return true; + } + + public int CompareTo(SemVer other) + { + var byMajor = Major.CompareTo(other.Major); + if (byMajor != 0) + { + return byMajor; + } + + var byMinor = Minor.CompareTo(other.Minor); + if (byMinor != 0) + { + return byMinor; + } + + var byPatch = Patch.CompareTo(other.Patch); + if (byPatch != 0) + { + return byPatch; + } + + // SemVer: a prerelease sorts BELOW its release (1.0.0-x < 1.0.0). + if (Prerelease.Length == 0 && other.Prerelease.Length == 0) + { + return 0; + } + + if (Prerelease.Length == 0) + { + return 1; + } + + if (other.Prerelease.Length == 0) + { + return -1; + } + + return string.CompareOrdinal(Prerelease, other.Prerelease); + } + + public override string ToString() => + Prerelease.Length == 0 + ? $"{Major}.{Minor}.{Patch}" + : $"{Major}.{Minor}.{Patch}-{Prerelease}"; + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/SmConfig.cs b/cli/SimpleModule.Cli/Infrastructure/SmConfig.cs new file mode 100644 index 00000000..026685a0 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/SmConfig.cs @@ -0,0 +1,59 @@ +using System.Text.Json; + +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Solution-level CLI configuration stored in sm.json at the solution root. +/// The registry URL abstracts the package feed so a marketplace feed can replace +/// nuget.org later without CLI changes. +/// +public sealed class SmConfig +{ + public const string FileName = "sm.json"; + public const string DefaultRegistry = "https://api.nuget.org/v3/index.json"; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + WriteIndented = true, + }; + + /// NuGet V3 service index URL used for search, resolve and download. + public string Registry { get; set; } = DefaultRegistry; + + public static SmConfig Load(string solutionRoot) + { + var path = Path.Combine(solutionRoot, FileName); + if (!File.Exists(path)) + { + return new SmConfig(); + } + + try + { + var config = JsonSerializer.Deserialize( + File.ReadAllText(path), + SerializerOptions + ); + if (config is null || string.IsNullOrWhiteSpace(config.Registry)) + { + return new SmConfig(); + } + + return config; + } + catch (JsonException) + { + // A broken sm.json should not brick every command; commands that care + // can warn. Fall back to the public registry. + return new SmConfig(); + } + } + + public void Save(string solutionRoot) + { + var path = Path.Combine(solutionRoot, FileName); + File.WriteAllText(path, JsonSerializer.Serialize(this, SerializerOptions)); + } +} diff --git a/docs/superpowers/plans/2026-06-10-module-packaging-session2.md b/docs/superpowers/plans/2026-06-10-module-packaging-session2.md new file mode 100644 index 00000000..46a692e3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-module-packaging-session2.md @@ -0,0 +1,102 @@ +# Module Packaging — Session 2 (Pack & Add) 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:** `sm pack` produces a validated module nupkg (production frontend build, externals check, manifest check, tests); `sm add` installs a packaged module into a host (compat check first, CPM-aware reference, local-feed nuget.config, migration run, auto-doctor); `sm remove` reverses it with a schema warning; `sm list` shows packaged modules with compat status. + +**Architecture:** New CLI infrastructure shared with Session 3: `SmConfig` (sm.json registry abstraction), `NuGetClient` (V3 service-index + flat-container), `NupkgManifestReader`/`AssemblyManifestReader` (System.Reflection.Metadata — no assembly loading), `FrameworkCompatChecker` (SemVer ranges `>=X `/`AsyncCommand`, `SolutionContext.Discover()`, exit codes 0/1, AnsiConsole markup; tests exercise infra classes directly with temp dirs (no CommandApp). +- `sm install` already runs `dotnet add package` (kept as the dumb low-level command; `sm add` is the module-aware one). +- Scaffolded hosts use CPM (`Directory.Packages.props` with `SimpleModule.Core` pinned) — host framework version is readable from there; fallback `version.json`. +- Doctor: 12 `IDoctorCheck` classes returning `CheckResult(Name, Status, Message)`; auto-fix by name prefix. +- Externals actually host-provided today: react, react-dom, react/jsx-runtime, react-dom/client, @inertiajs/react (`packages/SimpleModule.Client/src/vite-plugin-vendor.ts:13`). `@simplemodule/ui` is NOT vendored → pack validates the react/inertia set; UI externalization filed as a packaging issue (out of scope here). +- In-repo modules: `modules/Directory.Build.targets` runs Vite before `GenerateNuspec`; default JsBuildCommand is a production build, but a stale dev stamp can leave dev bundles (issue #260) — `sm pack` always runs a fresh production Vite build first. + +--- + +### Task 1: CLI infra — SmConfig, FrameworkCompatChecker + +**Files:** Create `cli/SimpleModule.Cli/Infrastructure/SmConfig.cs`, `cli/SimpleModule.Cli/Infrastructure/FrameworkCompatChecker.cs`; tests `tests/SimpleModule.Cli.Tests/SmConfigTests.cs`, `FrameworkCompatCheckerTests.cs`. + +- `SmConfig.Load(solutionRoot)` → reads `sm.json` (`{"registry": "url"}`); missing file/field → default `https://api.nuget.org/v3/index.json`. `Save` for completeness. +- `FrameworkCompatChecker.IsCompatible(range, version)` parsing `>=X.Y.Z[-pre] =X.Y.Z`, empty range → compatible-with-warning (`CompatResult { Compatible, Reason }`). SemVer compare incl. numeric segments + prerelease ordering. +- TDD: tests first, then impl, commit. + +### Task 2: CLI infra — manifest readers (no assembly load) + +**Files:** Create `cli/SimpleModule.Cli/Infrastructure/AssemblyManifestReader.cs` (System.Reflection.Metadata: find assembly-level custom attribute whose type name is `SimpleModule.Core.Modules.ModuleManifestAttribute`, decode the single string fixed arg from the value blob), `cli/SimpleModule.Cli/Infrastructure/NupkgManifestReader.cs` (ZipArchive: prefer `module-manifest.json` at package root, else first `lib/*/.dll` via AssemblyManifestReader), plus a tiny `ModuleManifestData` DTO (CLI-local POCO mirroring schema v1 — the CLI does not reference SimpleModule.Core). +**Tests:** `AssemblyManifestReaderTests` emits a temp assembly via `PersistedAssemblyBuilder` carrying a same-named attribute with JSON; `NupkgManifestReaderTests` zips a fixture. + +### Task 3: CLI infra — PackageReferenceManipulator + NuGetConfigManipulator + HostFrameworkVersion + +**Files:** Create `cli/SimpleModule.Cli/Infrastructure/PackageReferenceManipulator.cs` (CPM detect: Directory.Packages.props w/ `ManagePackageVersionsCentrally=true` → add/update `` + versionless `` in host csproj; non-CPM → inline `Version`; `Remove*` counterparts; idempotent), `NuGetConfigManipulator.cs` (ensure `nuget.config` at root has a named `` source; create file with ``-free defaults if missing), `HostFrameworkVersionResolver.cs` (CPM `SimpleModule.Core` PackageVersion → else `version.json` → else null). +**Tests:** temp-dir round-trips for CPM and non-CPM, idempotency, removal. + +### Task 4: CLI infra — NuGetClient + BundleExternalsValidator + +**Files:** Create `cli/SimpleModule.Cli/Infrastructure/NuGetClient.cs` — given registry service-index URL: resolve `PackageBaseAddress/3.0.0` resource, `GetVersionsAsync(id)`, `DownloadNupkgAsync(id, version, destPath)`; local-directory sources bypass HTTP (`FindLocalNupkg(dir, id, version?)` choosing highest version). Create `BundleExternalsValidator.cs` — scan built JS files (wwwroot `*.pages.js` + sibling chunks): FAIL when a file contains an inlined-React marker (`Symbol.for("react.element")`, `Symbol.for("react.transitional.element")`, `react.production`, `react.development`, `__CLIENT_INTERNALS_DO_NOT_USE`); WARN when no file imports `react` at all. Returns structured violations. +**Tests:** URL/version selection for local feed; validator against fixture strings (externalized vs inlined). + +### Task 5: framework — migrate-only hook + +**Files:** Modify `framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs`: in `UseSimpleModuleInfrastructure`, `var migrateOnly = Environment.GetEnvironmentVariable("SIMPLEMODULE_MIGRATE_ONLY") == "1"`; run the DB-init block when `migrateOnly || `; after init, if migrateOnly → log summary and `Environment.Exit(0)` (documented CLI-only entry point; addresses #258 for installs). +**Tests:** existing suites stay green (hook is env-gated); note in docs. + +### Task 6: `sm pack` + +**Files:** Create `cli/SimpleModule.Cli/Commands/Pack/PackCommand.cs` + `PackSettings` (`[module-path]` arg; `--output`, `--version`, `--skip-tests`, `--configuration` default Release). Register in Program.cs. +Pipeline (each step fails closed with an actionable message): +1. Resolve module dir (arg path, or `modules/`-style lookup via SolutionContext; must contain exactly one impl csproj, optionally a sibling `.Contracts`). +2. Frontend: if `package.json` → run `npx vite build --configLoader runner` (production default) in module dir; then `BundleExternalsValidator` on `wwwroot/` output. +3. `dotnet build -c Release [-p:Version=X]`. +4. Unless `--skip-tests`: `dotnet test ` when present. +5. Read manifest from built dll (AssemblyManifestReader): require parseable JSON, `schemaVersion==1`, `id == assembly name`; if `frontendEntry` non-null require the wwwroot bundle exists. Write `module-manifest.json` to the module project dir (packed via the `None Pack` item added in modules/Directory.Build.props + module template). +6. `dotnet pack -c Release --no-build -o ` for impl (and Contracts when present); print nupkg paths. +Also: add to `modules/Directory.Build.props` (and the CLI module template csproj) ``, and gitignore `module-manifest.json`. +**Tests:** step orchestration is in small static helpers (`PackPipeline`) tested directly: module-dir resolution, manifest validation rules, output path handling. Subprocess steps mocked behind a `ProcessRunner` seam (`Func` injection or interface) so tests don't shell out. + +### Task 7: `sm add` + +**Files:** Create `cli/SimpleModule.Cli/Commands/Add/AddCommand.cs` + settings (``, `--version`, `--source`, `--skip-migrations`, `--skip-doctor`). Register in Program.cs. +Pipeline: +1. `SolutionContext.Discover()`; resolve source: `--source` (dir or URL) → else `sm.json` registry. +2. Obtain nupkg (local find / NuGet download to temp) → `NupkgManifestReader` → manifest. No manifest → fail: "not a SimpleModule module package". +3. Compat gate BEFORE any file change: `HostFrameworkVersionResolver` + `FrameworkCompatChecker`; incompatible → fail with both versions printed. +4. Local dir source → `NuGetConfigManipulator.EnsureSource`. +5. `PackageReferenceManipulator.Add` (CPM-aware) into host csproj. +6. `dotnet restore` + `dotnet build` host (ProcessRunner). +7. Unless `--skip-migrations` and when `manifest.hasDbContext`: run host with `SIMPLEMODULE_MIGRATE_ONLY=1` (`dotnet run --project --no-build`); non-zero → report + exit 1. +8. Unless `--skip-doctor`: run doctor checks in-process — refactor `DoctorCommand` to expose `static List RunChecks(SolutionContext)` reused by both commands. +Print summary: module display name, version, schema, permissions count, frontend entry. +**Tests:** decision logic helpers (source resolution, compat gating, reference wiring) against temp dirs; ProcessRunner seam mocked. + +### Task 8: `sm remove` + +**Files:** Create `cli/SimpleModule.Cli/Commands/Remove/RemoveCommand.cs` (``). Look up manifest from `~/.nuget/packages///lib/*/dll` (best effort) BEFORE removal to name the schema; `PackageReferenceManipulator.Remove` from host csproj + CPM entry; ALWAYS print a prominent warning: database schema `` and its tables were left in place (data is not dropped) + what was left. Exit 0. +**Tests:** removal round-trip + warning content via captured console (AnsiConsole.Record or just test the helper that builds the message). + +### Task 9: `sm list` packaged-modules section + +**Files:** Modify `cli/SimpleModule.Cli/Commands/List/ListCommand.cs`: after the source-modules table, parse host csproj `PackageReference`s (+ CPM versions); for each, try manifest from the global packages cache (`NUGET_PACKAGES` env or `~/.nuget/packages`); render second table: Package, Version, Module, Framework compat (range + ✓/✗ vs host version). Packages without a manifest are skipped (not SimpleModule modules). +**Tests:** the csproj/CPM parsing helper + compat rendering decision. + +### Task 10: Session 2 checkpoint (scratch host) + +1. `VER=0.0.99-local` — pack framework packages referenced by the scaffolded host template (`Core`, `Database`, `Hosting`, `Generator`, + whatever the template lists) from the worktree with `-p:Version=$VER` into `$CLAUDE_JOB_DIR/tmp/feed2`. +2. `sm pack modules/FeatureFlags --version $VER --output feed2` (from the worktree solution). +3. `sm new project Demo` in a temp dir pinned to `$VER` (use the version option on `new project`; verify its name first), npm install. +4. `cd Demo && sm add SimpleModule.FeatureFlags --version $VER --source feed2` → expect: compat gate passes, nuget.config gains the feed, CPM entry added, build OK, migrate-only run OK, doctor green. +5. `dotnet test` in Demo; run Demo host; Playwright: log in, `/feature-flags/manage` renders. +6. `sm remove SimpleModule.FeatureFlags` → reference gone, schema warning printed. + +### Task 11: verification + docs + +- Full `dotnet build` (0 warnings), CLI tests (`dotnet run --project tests/SimpleModule.Cli.Tests`), all other suites, `npm run check`, `validate-pages`. +- Update `docs/site/cli/` with pack/add/remove/list reference page; extend `docs/site/advanced/module-packaging.md` (module-manifest.json in nupkg, migrate-only hook). +- Commits per task; checkpoint report; then `/code-review`. + +**Out of scope (Session 3):** publish/search/upgrade, doctor packaging checks beyond reuse, marketplace registration. diff --git a/tests/SimpleModule.Cli.Tests/FrameworkCompatCheckerTests.cs b/tests/SimpleModule.Cli.Tests/FrameworkCompatCheckerTests.cs new file mode 100644 index 00000000..58293865 --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/FrameworkCompatCheckerTests.cs @@ -0,0 +1,65 @@ +using FluentAssertions; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public class FrameworkCompatCheckerTests +{ + [Theory] + [InlineData(">=0.0.38 <1.0.0", "0.0.38", true)] + [InlineData(">=0.0.38 <1.0.0", "0.0.99", true)] + [InlineData(">=0.0.38 <1.0.0", "0.0.37", false)] + [InlineData(">=0.0.38 <1.0.0", "1.0.0", false)] + [InlineData(">=0.0.38 <1.0.0", "1.2.3", false)] + [InlineData(">=1.0.0", "1.0.0", true)] + [InlineData(">=1.0.0", "0.9.9", false)] + [InlineData(">=1.0.0", "99.0.0", true)] + public void IsCompatible_EvaluatesRanges(string range, string version, bool expected) + { + var result = FrameworkCompatChecker.Check(range, version); + + result.Compatible.Should().Be(expected); + } + + [Theory] + [InlineData(">=0.0.39-local <1.0.0", "0.0.39-local", true)] + [InlineData(">=0.0.39 <1.0.0", "0.0.39-local", false)] // prerelease < release + [InlineData(">=0.0.39-alpha <1.0.0", "0.0.39", true)] // release > prerelease + public void IsCompatible_HandlesPrereleaseOrdering(string range, string version, bool expected) + { + FrameworkCompatChecker.Check(range, version).Compatible.Should().Be(expected); + } + + [Fact] + public void EmptyRange_IsCompatibleWithWarning() + { + var result = FrameworkCompatChecker.Check("", "1.0.0"); + + result.Compatible.Should().BeTrue(); + result.Reason.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void UnparseableRange_IsIncompatibleWithReason() + { + var result = FrameworkCompatChecker.Check("banana", "1.0.0"); + + result.Compatible.Should().BeFalse(); + result.Reason.Should().Contain("banana"); + } + + [Fact] + public void UnparseableHostVersion_IsIncompatibleWithReason() + { + var result = FrameworkCompatChecker.Check(">=1.0.0", "not-a-version"); + + result.Compatible.Should().BeFalse(); + result.Reason.Should().Contain("not-a-version"); + } + + [Fact] + public void NumericSegments_CompareNumericallyNotLexically() + { + FrameworkCompatChecker.Check(">=0.0.9 <1.0.0", "0.0.10").Compatible.Should().BeTrue(); + } +} diff --git a/tests/SimpleModule.Cli.Tests/SmConfigTests.cs b/tests/SimpleModule.Cli.Tests/SmConfigTests.cs new file mode 100644 index 00000000..9fb4efb3 --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/SmConfigTests.cs @@ -0,0 +1,74 @@ +using FluentAssertions; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class SmConfigTests : IDisposable +{ + private readonly string _tempDir = Path.Combine( + Path.GetTempPath(), + "sm-config-tests-" + Guid.NewGuid().ToString("N") + ); + + public SmConfigTests() => Directory.CreateDirectory(_tempDir); + + [Fact] + public void Load_NoFile_ReturnsNuGetOrgDefault() + { + var config = SmConfig.Load(_tempDir); + + config.Registry.Should().Be("https://api.nuget.org/v3/index.json"); + } + + [Fact] + public void Load_FileWithRegistry_ReturnsConfiguredRegistry() + { + File.WriteAllText( + Path.Combine(_tempDir, "sm.json"), + """{"registry": "https://my-feed.example.com/v3/index.json"}""" + ); + + var config = SmConfig.Load(_tempDir); + + config.Registry.Should().Be("https://my-feed.example.com/v3/index.json"); + } + + [Fact] + public void Load_FileWithoutRegistryField_FallsBackToDefault() + { + File.WriteAllText(Path.Combine(_tempDir, "sm.json"), """{"otherSetting": true}"""); + + var config = SmConfig.Load(_tempDir); + + config.Registry.Should().Be("https://api.nuget.org/v3/index.json"); + } + + [Fact] + public void Load_MalformedJson_FallsBackToDefault() + { + File.WriteAllText(Path.Combine(_tempDir, "sm.json"), "{not json"); + + var config = SmConfig.Load(_tempDir); + + config.Registry.Should().Be("https://api.nuget.org/v3/index.json"); + } + + [Fact] + public void Save_ThenLoad_RoundTrips() + { + var config = new SmConfig { Registry = "https://feed.example/v3/index.json" }; + config.Save(_tempDir); + + SmConfig.Load(_tempDir).Registry.Should().Be("https://feed.example/v3/index.json"); + } + + public void Dispose() + { + try + { + Directory.Delete(_tempDir, recursive: true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} From 35048358f454700f8300a70220bbea1e516fe5e3 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 11:48:38 +0200 Subject: [PATCH 11/31] feat(cli): read module manifests from assemblies and nupkgs without loading --- .../Infrastructure/AssemblyManifestReader.cs | 97 ++++++++++ .../Infrastructure/ModuleManifestData.cs | 45 +++++ .../Infrastructure/NupkgManifestReader.cs | 62 ++++++ .../ManifestReaderTests.cs | 183 ++++++++++++++++++ 4 files changed, 387 insertions(+) create mode 100644 cli/SimpleModule.Cli/Infrastructure/AssemblyManifestReader.cs create mode 100644 cli/SimpleModule.Cli/Infrastructure/ModuleManifestData.cs create mode 100644 cli/SimpleModule.Cli/Infrastructure/NupkgManifestReader.cs create mode 100644 tests/SimpleModule.Cli.Tests/ManifestReaderTests.cs diff --git a/cli/SimpleModule.Cli/Infrastructure/AssemblyManifestReader.cs b/cli/SimpleModule.Cli/Infrastructure/AssemblyManifestReader.cs new file mode 100644 index 00000000..602e4e11 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/AssemblyManifestReader.cs @@ -0,0 +1,97 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Reads the [assembly: ModuleManifest("{json}")] attribute from a module +/// assembly using System.Reflection.Metadata — the assembly is never loaded, so +/// this works without resolving its dependencies (framework, ASP.NET, ...). +/// +public static class AssemblyManifestReader +{ + private const string AttributeNamespace = "SimpleModule.Core.Modules"; + private const string AttributeName = "ModuleManifestAttribute"; + + public static ModuleManifestData? TryRead(string assemblyPath) + { + var json = TryReadJson(assemblyPath); + return json is null ? null : ModuleManifestData.TryParse(json); + } + + public static string? TryReadJson(string assemblyPath) + { + if (!File.Exists(assemblyPath)) + { + return null; + } + + using var stream = File.OpenRead(assemblyPath); + using var peReader = new PEReader(stream); + if (!peReader.HasMetadata) + { + return null; + } + + var reader = peReader.GetMetadataReader(); + foreach (var handle in reader.GetAssemblyDefinition().GetCustomAttributes()) + { + var attribute = reader.GetCustomAttribute(handle); + if (!IsModuleManifestAttribute(reader, attribute)) + { + continue; + } + + // Value blob: 0x0001 prolog, then the single string fixed argument. + var blobReader = reader.GetBlobReader(attribute.Value); + if (blobReader.ReadUInt16() != 0x0001) + { + return null; + } + + return blobReader.ReadSerializedString(); + } + + return null; + } + + private static bool IsModuleManifestAttribute(MetadataReader reader, CustomAttribute attribute) + { + StringHandle nameHandle; + StringHandle namespaceHandle; + + switch (attribute.Constructor.Kind) + { + case HandleKind.MemberReference: + { + var member = reader.GetMemberReference( + (MemberReferenceHandle)attribute.Constructor + ); + if (member.Parent.Kind != HandleKind.TypeReference) + { + return false; + } + + var type = reader.GetTypeReference((TypeReferenceHandle)member.Parent); + nameHandle = type.Name; + namespaceHandle = type.Namespace; + break; + } + case HandleKind.MethodDefinition: + { + var method = reader.GetMethodDefinition( + (MethodDefinitionHandle)attribute.Constructor + ); + var type = reader.GetTypeDefinition(method.GetDeclaringType()); + nameHandle = type.Name; + namespaceHandle = type.Namespace; + break; + } + default: + return false; + } + + return reader.StringComparer.Equals(nameHandle, AttributeName) + && reader.StringComparer.Equals(namespaceHandle, AttributeNamespace); + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/ModuleManifestData.cs b/cli/SimpleModule.Cli/Infrastructure/ModuleManifestData.cs new file mode 100644 index 00000000..14f0826a --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/ModuleManifestData.cs @@ -0,0 +1,45 @@ +using System.Text.Json; + +namespace SimpleModule.Cli.Infrastructure; + +/// +/// CLI-local view of the module manifest (schema v1) emitted by +/// SimpleModule.Generator. Kept independent of SimpleModule.Core so the CLI +/// has no framework assembly dependency. +/// +public sealed class ModuleManifestData +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + public int SchemaVersion { get; init; } + public string Id { get; init; } = ""; + public string Name { get; init; } = ""; + public string DisplayName { get; init; } = ""; + public string Version { get; init; } = ""; + public string FrameworkCompat { get; init; } = ""; + public string RoutePrefix { get; init; } = ""; + public string ViewPrefix { get; init; } = ""; + public string Schema { get; init; } = ""; + public IReadOnlyList Permissions { get; init; } = []; + public string? FrontendEntry { get; init; } + public IReadOnlyList Pages { get; init; } = []; + public IReadOnlyList EventsPublished { get; init; } = []; + public IReadOnlyList EventsConsumed { get; init; } = []; + public bool HasDbContext { get; init; } + + public static ModuleManifestData? TryParse(string json) + { + try + { + return JsonSerializer.Deserialize(json, SerializerOptions); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/NupkgManifestReader.cs b/cli/SimpleModule.Cli/Infrastructure/NupkgManifestReader.cs new file mode 100644 index 00000000..3419d57f --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/NupkgManifestReader.cs @@ -0,0 +1,62 @@ +using System.IO.Compression; + +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Extracts the module manifest from a nupkg: prefers the +/// module-manifest.json file at the package root (written by +/// sm pack), falling back to the [assembly: ModuleManifest] +/// attribute on the package's main assembly. +/// +public static class NupkgManifestReader +{ + public static ModuleManifestData? TryRead(string nupkgPath, string packageId) + { + if (!File.Exists(nupkgPath)) + { + return null; + } + + using var zip = ZipFile.OpenRead(nupkgPath); + + var manifestEntry = zip.GetEntry("module-manifest.json"); + if (manifestEntry is not null) + { + using var reader = new StreamReader(manifestEntry.Open()); + var parsed = ModuleManifestData.TryParse(reader.ReadToEnd()); + if (parsed is not null) + { + return parsed; + } + } + + var dllName = packageId + ".dll"; + var dllEntry = zip.Entries.FirstOrDefault(e => + e.FullName.StartsWith("lib/", StringComparison.OrdinalIgnoreCase) + && string.Equals(e.Name, dllName, StringComparison.OrdinalIgnoreCase) + ); + if (dllEntry is null) + { + return null; + } + + // PEReader needs a seekable stream; zip entry streams are not. + var tempDll = Path.Combine( + Path.GetTempPath(), + "sm-" + Guid.NewGuid().ToString("N") + ".dll" + ); + try + { + dllEntry.ExtractToFile(tempDll); + return AssemblyManifestReader.TryRead(tempDll); + } + finally + { + try + { + File.Delete(tempDll); + } + catch (IOException) { } + } + } +} diff --git a/tests/SimpleModule.Cli.Tests/ManifestReaderTests.cs b/tests/SimpleModule.Cli.Tests/ManifestReaderTests.cs new file mode 100644 index 00000000..ca22d9e4 --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/ManifestReaderTests.cs @@ -0,0 +1,183 @@ +using System.IO.Compression; +using System.Reflection; +using System.Reflection.Emit; +using FluentAssertions; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class ManifestReaderTests : IDisposable +{ + private const string SampleJson = """ + {"schemaVersion":1,"id":"SimpleModule.X","name":"X","displayName":"X Module","version":"1.0.0","frameworkCompat":">=0.0.38 <1.0.0","routePrefix":"/api/x","viewPrefix":"/x","schema":"X","permissions":["X.View"],"frontendEntry":"_content/SimpleModule.X/SimpleModule.X.pages.js","pages":["X/Browse"],"eventsPublished":[],"eventsConsumed":[],"hasDbContext":true} + """; + + private readonly string _tempDir = Path.Combine( + Path.GetTempPath(), + "sm-manifest-tests-" + Guid.NewGuid().ToString("N") + ); + + public ManifestReaderTests() => Directory.CreateDirectory(_tempDir); + + [Fact] + public void AssemblyManifestReader_ReadsManifestFromAttribute() + { + var dllPath = EmitAssemblyWithManifest("SimpleModule.X", SampleJson); + + var manifest = AssemblyManifestReader.TryRead(dllPath); + + manifest.Should().NotBeNull(); + manifest!.Id.Should().Be("SimpleModule.X"); + manifest.Name.Should().Be("X"); + manifest.FrameworkCompat.Should().Be(">=0.0.38 <1.0.0"); + manifest.Schema.Should().Be("X"); + manifest.HasDbContext.Should().BeTrue(); + manifest.FrontendEntry.Should().Be("_content/SimpleModule.X/SimpleModule.X.pages.js"); + } + + [Fact] + public void AssemblyManifestReader_ReturnsNullForAssemblyWithoutManifest() + { + var dllPath = EmitAssemblyWithManifest("Plain.Assembly", manifestJson: null); + + AssemblyManifestReader.TryRead(dllPath).Should().BeNull(); + } + + [Fact] + public void NupkgManifestReader_PrefersManifestJsonFile() + { + var nupkgPath = Path.Combine(_tempDir, "SimpleModule.X.1.0.0.nupkg"); + using (var zip = ZipFile.Open(nupkgPath, ZipArchiveMode.Create)) + { + WriteEntry(zip, "module-manifest.json", SampleJson); + } + + var manifest = NupkgManifestReader.TryRead(nupkgPath, "SimpleModule.X"); + + manifest.Should().NotBeNull(); + manifest!.Id.Should().Be("SimpleModule.X"); + } + + [Fact] + public void NupkgManifestReader_FallsBackToAssemblyAttribute() + { + var dllPath = EmitAssemblyWithManifest("SimpleModule.X", SampleJson); + var nupkgPath = Path.Combine(_tempDir, "SimpleModule.X.1.0.1.nupkg"); + using (var zip = ZipFile.Open(nupkgPath, ZipArchiveMode.Create)) + { + zip.CreateEntryFromFile(dllPath, "lib/net10.0/SimpleModule.X.dll"); + } + + var manifest = NupkgManifestReader.TryRead(nupkgPath, "SimpleModule.X"); + + manifest.Should().NotBeNull(); + manifest!.Name.Should().Be("X"); + } + + [Fact] + public void NupkgManifestReader_ReturnsNullForNonModulePackage() + { + var nupkgPath = Path.Combine(_tempDir, "Plain.Package.1.0.0.nupkg"); + using (var zip = ZipFile.Open(nupkgPath, ZipArchiveMode.Create)) + { + WriteEntry(zip, "lib/net10.0/readme.txt", "hello"); + } + + NupkgManifestReader.TryRead(nupkgPath, "Plain.Package").Should().BeNull(); + } + + private static void WriteEntry(ZipArchive zip, string name, string content) + { + var entry = zip.CreateEntry(name); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } + + private static ConstructorInfo? _attributeCtor; + + /// + /// Production module assemblies reference ModuleManifestAttribute from + /// SimpleModule.Core, so the attribute constructor is a MemberReference to a + /// TypeReference in another assembly. Recreate that exact shape: emit the + /// attribute type into its own assembly once, load it, then reference its + /// constructor from the test module assembly. + /// + private static ConstructorInfo GetAttributeCtor() + { + if (_attributeCtor is not null) + { + return _attributeCtor; + } + + var builder = new PersistedAssemblyBuilder( + new AssemblyName("SmTestAttributeAssembly"), + typeof(object).Assembly + ); + var module = builder.DefineDynamicModule("SmTestAttributeAssembly"); + var attrType = module.DefineType( + "SimpleModule.Core.Modules.ModuleManifestAttribute", + TypeAttributes.Public | TypeAttributes.Sealed, + typeof(Attribute) + ); + var ctor = attrType.DefineConstructor( + MethodAttributes.Public, + CallingConventions.Standard, + [typeof(string)] + ); + var il = ctor.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit( + OpCodes.Call, + typeof(Attribute).GetConstructor( + BindingFlags.NonPublic | BindingFlags.Instance, + Type.EmptyTypes + )! + ); + il.Emit(OpCodes.Ret); + attrType.CreateType(); + + var attrDllPath = Path.Combine( + Path.GetTempPath(), + "sm-test-attr-" + Guid.NewGuid().ToString("N") + ".dll" + ); + builder.Save(attrDllPath); + + var loaded = Assembly.LoadFile(attrDllPath); + _attributeCtor = loaded + .GetType("SimpleModule.Core.Modules.ModuleManifestAttribute")! + .GetConstructor([typeof(string)])!; + return _attributeCtor; + } + + private string EmitAssemblyWithManifest(string assemblyName, string? manifestJson) + { + var builder = new PersistedAssemblyBuilder( + new AssemblyName(assemblyName), + typeof(object).Assembly + ); + var module = builder.DefineDynamicModule(assemblyName); + // The module needs at least one type for a well-formed assembly. + module.DefineType("Placeholder.Anchor", TypeAttributes.Public).CreateType(); + + if (manifestJson is not null) + { + builder.SetCustomAttribute( + new CustomAttributeBuilder(GetAttributeCtor(), [manifestJson]) + ); + } + + var dllPath = Path.Combine(_tempDir, assemblyName + ".dll"); + builder.Save(dllPath); + return dllPath; + } + + public void Dispose() + { + try + { + Directory.Delete(_tempDir, recursive: true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} From 46f444c6ae045a945e9185d55b70d76cc7d3c85c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 11:50:57 +0200 Subject: [PATCH 12/31] feat(cli): CPM-aware package reference, nuget.config and host version helpers --- .../HostFrameworkVersionResolver.cs | 49 ++++ .../Infrastructure/NuGetConfigManipulator.cs | 55 +++++ .../PackageReferenceManipulator.cs | 217 ++++++++++++++++++ .../HostFrameworkVersionResolverTests.cs | 55 +++++ .../NuGetConfigManipulatorTests.cs | 90 ++++++++ .../PackageReferenceManipulatorTests.cs | 183 +++++++++++++++ 6 files changed, 649 insertions(+) create mode 100644 cli/SimpleModule.Cli/Infrastructure/HostFrameworkVersionResolver.cs create mode 100644 cli/SimpleModule.Cli/Infrastructure/NuGetConfigManipulator.cs create mode 100644 cli/SimpleModule.Cli/Infrastructure/PackageReferenceManipulator.cs create mode 100644 tests/SimpleModule.Cli.Tests/HostFrameworkVersionResolverTests.cs create mode 100644 tests/SimpleModule.Cli.Tests/NuGetConfigManipulatorTests.cs create mode 100644 tests/SimpleModule.Cli.Tests/PackageReferenceManipulatorTests.cs diff --git a/cli/SimpleModule.Cli/Infrastructure/HostFrameworkVersionResolver.cs b/cli/SimpleModule.Cli/Infrastructure/HostFrameworkVersionResolver.cs new file mode 100644 index 00000000..f43b4519 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/HostFrameworkVersionResolver.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Determines the SimpleModule framework version a host application targets: +/// the CPM-pinned SimpleModule.Core PackageVersion, else version.json (the +/// in-repo development convention), else unknown. +/// +public static partial class HostFrameworkVersionResolver +{ + public static string? Resolve(string solutionRoot) + { + var propsPath = Path.Combine(solutionRoot, "Directory.Packages.props"); + if (File.Exists(propsPath)) + { + var match = CorePackageVersionRegex().Match(File.ReadAllText(propsPath)); + if (match.Success) + { + return match.Groups["version"].Value; + } + } + + var versionJsonPath = Path.Combine(solutionRoot, "version.json"); + if (File.Exists(versionJsonPath)) + { + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(versionJsonPath)); + if (doc.RootElement.TryGetProperty("version", out var version)) + { + return version.GetString(); + } + } + catch (JsonException) + { + // fall through to null + } + } + + return null; + } + + [GeneratedRegex( + "[^\"]+)\"" + )] + private static partial Regex CorePackageVersionRegex(); +} diff --git a/cli/SimpleModule.Cli/Infrastructure/NuGetConfigManipulator.cs b/cli/SimpleModule.Cli/Infrastructure/NuGetConfigManipulator.cs new file mode 100644 index 00000000..f5b0c9ff --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/NuGetConfigManipulator.cs @@ -0,0 +1,55 @@ +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Ensures a local folder feed is registered in the solution's nuget.config so +/// restores can resolve module packages added from a local source. +/// +public static class NuGetConfigManipulator +{ + public static void EnsureLocalSource(string solutionRoot, string feedDirectory) + { + var configPath = Path.Combine(solutionRoot, "nuget.config"); + var normalizedFeed = Path.GetFullPath(feedDirectory); + + if (!File.Exists(configPath)) + { + File.WriteAllText( + configPath, + $""" + + + + + + + + """ + ); + return; + } + + var content = File.ReadAllText(configPath); + if (content.Contains(normalizedFeed, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var lines = File.ReadAllLines(configPath).ToList(); + var close = lines.FindIndex(l => l.Contains("", StringComparison.Ordinal)); + if (close < 0) + { + throw new InvalidOperationException( + $"{configPath} has no section; add the feed '{normalizedFeed}' manually." + ); + } + + lines.Insert( + close, + $" " + ); + File.WriteAllLines(configPath, lines); + } + + private static string SourceKey(string feedDirectory) => + "sm-local-" + Path.GetFileName(feedDirectory.TrimEnd(Path.DirectorySeparatorChar)); +} diff --git a/cli/SimpleModule.Cli/Infrastructure/PackageReferenceManipulator.cs b/cli/SimpleModule.Cli/Infrastructure/PackageReferenceManipulator.cs new file mode 100644 index 00000000..4acd916d --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/PackageReferenceManipulator.cs @@ -0,0 +1,217 @@ +using System.Text.RegularExpressions; + +namespace SimpleModule.Cli.Infrastructure; + +public readonly record struct PackageReferenceEntry(string Id, string? Version); + +/// +/// Adds/removes NuGet package references on a host project, transparently +/// handling Central Package Management: with CPM the version lives in +/// Directory.Packages.props and the csproj reference is version-less (NU1008); +/// without CPM the version is inlined on the reference. +/// All edits are line-based to preserve the user's file formatting. +/// +public static partial class PackageReferenceManipulator +{ + public static void AddPackage( + string csprojPath, + string solutionRoot, + string packageId, + string version + ) + { + var propsPath = CpmPropsPath(solutionRoot); + if (propsPath is not null) + { + SetCpmPackageVersion(propsPath, packageId, version); + InsertReferenceLine(csprojPath, $""); + } + else + { + InsertReferenceLine( + csprojPath, + $"" + ); + } + } + + public static bool RemovePackage(string csprojPath, string solutionRoot, string packageId) + { + var removed = RemoveElementLine(csprojPath, "PackageReference", packageId); + + var propsPath = CpmPropsPath(solutionRoot); + if (propsPath is not null) + { + removed |= RemoveElementLine(propsPath, "PackageVersion", packageId); + } + + return removed; + } + + public static IReadOnlyList GetPackageReferences( + string csprojPath, + string solutionRoot + ) + { + if (!File.Exists(csprojPath)) + { + return []; + } + + var cpmVersions = ReadCpmVersions(solutionRoot); + var results = new List(); + foreach (Match match in PackageReferenceRegex().Matches(File.ReadAllText(csprojPath))) + { + var id = match.Groups["id"].Value; + var version = match.Groups["version"].Success + ? match.Groups["version"].Value + : cpmVersions.GetValueOrDefault(id); + results.Add(new PackageReferenceEntry(id, version)); + } + + return results; + } + + private static string? CpmPropsPath(string solutionRoot) + { + var path = Path.Combine(solutionRoot, "Directory.Packages.props"); + return File.Exists(path) ? path : null; + } + + private static Dictionary ReadCpmVersions(string solutionRoot) + { + var versions = new Dictionary(StringComparer.OrdinalIgnoreCase); + var propsPath = CpmPropsPath(solutionRoot); + if (propsPath is null) + { + return versions; + } + + foreach (Match match in PackageVersionRegex().Matches(File.ReadAllText(propsPath))) + { + versions[match.Groups["id"].Value] = match.Groups["version"].Value; + } + + return versions; + } + + private static void SetCpmPackageVersion(string propsPath, string packageId, string version) + { + var lines = File.ReadAllLines(propsPath).ToList(); + var token = $" l.Contains(token, StringComparison.Ordinal)); + if (existing >= 0) + { + var indent = lines[existing][..^lines[existing].TrimStart().Length]; + lines[existing] = + $"{indent}"; + File.WriteAllLines(propsPath, lines); + return; + } + + var anchor = lines.FindLastIndex(l => + l.Contains(" l.Contains("", StringComparison.Ordinal)); + if (anchor < 0) + { + throw new InvalidOperationException( + $"Could not find an in {propsPath} to add the PackageVersion entry." + ); + } + } + + var indentation = DetectIndent(lines[anchor], fallback: " "); + lines.Insert( + anchor + 1, + $"{indentation}" + ); + File.WriteAllLines(propsPath, lines); + } + + private static void InsertReferenceLine(string csprojPath, string element) + { + var content = File.ReadAllText(csprojPath); + var includeToken = ExtractIncludeToken(element); + if (content.Contains(includeToken, StringComparison.Ordinal)) + { + return; + } + + var lines = File.ReadAllLines(csprojPath).ToList(); + var anchor = lines.FindLastIndex(l => + l.Contains(" + l.Contains("= 0) + { + lines.Insert(anchor + 1, DetectIndent(lines[anchor], " ") + element); + } + else + { + var close = lines.FindIndex(l => l.Contains("", StringComparison.Ordinal)); + if (close < 0) + { + throw new InvalidOperationException($"{csprojPath} has no closing tag."); + } + + lines.Insert(close, " "); + lines.Insert(close + 1, " " + element); + lines.Insert(close + 2, " "); + } + + File.WriteAllLines(csprojPath, lines); + } + + private static bool RemoveElementLine(string filePath, string elementName, string packageId) + { + if (!File.Exists(filePath)) + { + return false; + } + + var token = $"<{elementName} Include=\"{packageId}\""; + var lines = File.ReadAllLines(filePath).ToList(); + var removedCount = lines.RemoveAll(l => l.Contains(token, StringComparison.Ordinal)); + if (removedCount > 0) + { + File.WriteAllLines(filePath, lines); + } + + return removedCount > 0; + } + + private static string DetectIndent(string line, string fallback) + { + var indent = line[..^line.TrimStart().Length]; + return indent.Length > 0 ? indent : fallback; + } + + private static string ExtractIncludeToken(string element) + { + var match = IncludeRegex().Match(element); + return match.Success ? match.Value : element; + } + + [GeneratedRegex( + "[^\"]+)\"(?:\\s+Version=\"(?[^\"]+)\")?" + )] + private static partial Regex PackageReferenceRegex(); + + [GeneratedRegex( + "[^\"]+)\"\\s+Version=\"(?[^\"]+)\"" + )] + private static partial Regex PackageVersionRegex(); + + [GeneratedRegex("Include=\"[^\"]+\"")] + private static partial Regex IncludeRegex(); +} diff --git a/tests/SimpleModule.Cli.Tests/HostFrameworkVersionResolverTests.cs b/tests/SimpleModule.Cli.Tests/HostFrameworkVersionResolverTests.cs new file mode 100644 index 00000000..f0308b21 --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/HostFrameworkVersionResolverTests.cs @@ -0,0 +1,55 @@ +using FluentAssertions; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class HostFrameworkVersionResolverTests : IDisposable +{ + private readonly string _tempDir = Path.Combine( + Path.GetTempPath(), + "sm-hostver-tests-" + Guid.NewGuid().ToString("N") + ); + + public HostFrameworkVersionResolverTests() => Directory.CreateDirectory(_tempDir); + + [Fact] + public void Resolve_FromCpmPackageVersion() + { + File.WriteAllText( + Path.Combine(_tempDir, "Directory.Packages.props"), + """ + + + + + + """ + ); + + HostFrameworkVersionResolver.Resolve(_tempDir).Should().Be("0.0.42"); + } + + [Fact] + public void Resolve_FallsBackToVersionJson() + { + File.WriteAllText(Path.Combine(_tempDir, "version.json"), """{"version": "0.0.7"}"""); + + HostFrameworkVersionResolver.Resolve(_tempDir).Should().Be("0.0.7"); + } + + [Fact] + public void Resolve_NothingFound_ReturnsNull() + { + HostFrameworkVersionResolver.Resolve(_tempDir).Should().BeNull(); + } + + public void Dispose() + { + try + { + Directory.Delete(_tempDir, recursive: true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} diff --git a/tests/SimpleModule.Cli.Tests/NuGetConfigManipulatorTests.cs b/tests/SimpleModule.Cli.Tests/NuGetConfigManipulatorTests.cs new file mode 100644 index 00000000..bc3aa7f4 --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/NuGetConfigManipulatorTests.cs @@ -0,0 +1,90 @@ +using FluentAssertions; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class NuGetConfigManipulatorTests : IDisposable +{ + private readonly string _tempDir = Path.Combine( + Path.GetTempPath(), + "sm-nugetconfig-tests-" + Guid.NewGuid().ToString("N") + ); + + public NuGetConfigManipulatorTests() => Directory.CreateDirectory(_tempDir); + + [Fact] + public void EnsureLocalSource_CreatesConfigWhenMissing() + { + var feedDir = Path.Combine(_tempDir, "feed"); + Directory.CreateDirectory(feedDir); + + NuGetConfigManipulator.EnsureLocalSource(_tempDir, feedDir); + + var configPath = Path.Combine(_tempDir, "nuget.config"); + File.Exists(configPath).Should().BeTrue(); + var content = File.ReadAllText(configPath); + content.Should().Contain(feedDir); + content.Should().Contain("nuget.org"); // public feed preserved + } + + [Fact] + public void EnsureLocalSource_AppendsToExistingConfig() + { + var configPath = Path.Combine(_tempDir, "nuget.config"); + File.WriteAllText( + configPath, + """ + + + + + + + """ + ); + var feedDir = Path.Combine(_tempDir, "feed"); + Directory.CreateDirectory(feedDir); + + NuGetConfigManipulator.EnsureLocalSource(_tempDir, feedDir); + + var content = File.ReadAllText(configPath); + content.Should().Contain(feedDir); + content.Should().Contain("nuget.org"); + } + + [Fact] + public void EnsureLocalSource_IsIdempotent() + { + var feedDir = Path.Combine(_tempDir, "feed"); + Directory.CreateDirectory(feedDir); + + NuGetConfigManipulator.EnsureLocalSource(_tempDir, feedDir); + NuGetConfigManipulator.EnsureLocalSource(_tempDir, feedDir); + + var content = File.ReadAllText(Path.Combine(_tempDir, "nuget.config")); + CountOf(content, feedDir).Should().Be(1); + } + + private static int CountOf(string text, string token) + { + var count = 0; + var index = 0; + while ((index = text.IndexOf(token, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += token.Length; + } + + return count; + } + + public void Dispose() + { + try + { + Directory.Delete(_tempDir, recursive: true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} diff --git a/tests/SimpleModule.Cli.Tests/PackageReferenceManipulatorTests.cs b/tests/SimpleModule.Cli.Tests/PackageReferenceManipulatorTests.cs new file mode 100644 index 00000000..708c6c29 --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/PackageReferenceManipulatorTests.cs @@ -0,0 +1,183 @@ +using FluentAssertions; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class PackageReferenceManipulatorTests : IDisposable +{ + private readonly string _tempDir = Path.Combine( + Path.GetTempPath(), + "sm-pkgref-tests-" + Guid.NewGuid().ToString("N") + ); + + private readonly string _csprojPath; + + public PackageReferenceManipulatorTests() + { + Directory.CreateDirectory(_tempDir); + _csprojPath = Path.Combine(_tempDir, "Demo.Host.csproj"); + File.WriteAllText( + _csprojPath, + """ + + + net10.0 + + + + + + """ + ); + } + + private void WriteCpmProps() + { + File.WriteAllText( + Path.Combine(_tempDir, "Directory.Packages.props"), + """ + + + true + + + + + + + """ + ); + } + + [Fact] + public void Add_Cpm_WritesPackageVersionAndVersionlessReference() + { + WriteCpmProps(); + + PackageReferenceManipulator.AddPackage( + _csprojPath, + _tempDir, + "SimpleModule.FeatureFlags", + "1.2.3" + ); + + File.ReadAllText(_csprojPath) + .Should() + .Contain("") + .And.NotContain("SimpleModule.FeatureFlags\" Version="); + File.ReadAllText(Path.Combine(_tempDir, "Directory.Packages.props")) + .Should() + .Contain(""); + } + + [Fact] + public void Add_NonCpm_WritesInlineVersion() + { + PackageReferenceManipulator.AddPackage( + _csprojPath, + _tempDir, + "SimpleModule.FeatureFlags", + "1.2.3" + ); + + File.ReadAllText(_csprojPath) + .Should() + .Contain( + "" + ); + } + + [Fact] + public void Add_IsIdempotent() + { + WriteCpmProps(); + + PackageReferenceManipulator.AddPackage(_csprojPath, _tempDir, "SimpleModule.X", "1.0.0"); + PackageReferenceManipulator.AddPackage(_csprojPath, _tempDir, "SimpleModule.X", "1.0.0"); + + CountOccurrences(File.ReadAllText(_csprojPath), "Include=\"SimpleModule.X\"") + .Should() + .Be(1); + CountOccurrences( + File.ReadAllText(Path.Combine(_tempDir, "Directory.Packages.props")), + "Include=\"SimpleModule.X\"" + ) + .Should() + .Be(1); + } + + [Fact] + public void Add_Cpm_UpdatesExistingPackageVersion() + { + WriteCpmProps(); + PackageReferenceManipulator.AddPackage(_csprojPath, _tempDir, "SimpleModule.X", "1.0.0"); + + PackageReferenceManipulator.AddPackage(_csprojPath, _tempDir, "SimpleModule.X", "2.0.0"); + + var props = File.ReadAllText(Path.Combine(_tempDir, "Directory.Packages.props")); + props.Should().Contain("Include=\"SimpleModule.X\" Version=\"2.0.0\""); + props + .Should() + .NotContain("Version=\"1.0.0\" />\n r.Id == "SimpleModule.X" && r.Version == "1.5.0"); + references.Should().Contain(r => r.Id == "SimpleModule.Hosting" && r.Version == "0.0.38"); + } + + private static int CountOccurrences(string text, string token) + { + var count = 0; + var index = 0; + while ((index = text.IndexOf(token, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += token.Length; + } + + return count; + } + + public void Dispose() + { + try + { + Directory.Delete(_tempDir, recursive: true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} From 671e68012195c28d8f033434d789d011fb96b4cd Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 11:52:46 +0200 Subject: [PATCH 13/31] feat(cli): minimal NuGet V3 client and frontend externals validator --- .../BundleExternalsValidator.cs | 58 ++++++ .../Infrastructure/NuGetClient.cs | 171 ++++++++++++++++++ .../BundleExternalsValidatorTests.cs | 100 ++++++++++ .../NuGetClientTests.cs | 83 +++++++++ 4 files changed, 412 insertions(+) create mode 100644 cli/SimpleModule.Cli/Infrastructure/BundleExternalsValidator.cs create mode 100644 cli/SimpleModule.Cli/Infrastructure/NuGetClient.cs create mode 100644 tests/SimpleModule.Cli.Tests/BundleExternalsValidatorTests.cs create mode 100644 tests/SimpleModule.Cli.Tests/NuGetClientTests.cs diff --git a/cli/SimpleModule.Cli/Infrastructure/BundleExternalsValidator.cs b/cli/SimpleModule.Cli/Infrastructure/BundleExternalsValidator.cs new file mode 100644 index 00000000..b131d1be --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/BundleExternalsValidator.cs @@ -0,0 +1,58 @@ +namespace SimpleModule.Cli.Infrastructure; + +public readonly record struct ExternalsViolation(string File, string Marker); + +/// +/// Verifies that a module's built frontend bundle externalizes the host-provided +/// libraries (react, react-dom, react/jsx-runtime, @inertiajs/react). A module +/// that inlines its own React copy breaks hooks at runtime (two React instances) +/// — pack fails closed when an inlined-React marker is found. +/// +public static class BundleExternalsValidator +{ + // Strings that only appear inside React's own source, never in code that + // imports React as an external. + private static readonly string[] InlinedReactMarkers = + [ + "Symbol.for(\"react.element\")", + "Symbol.for('react.element')", + "Symbol.for(\"react.transitional.element\")", + "Symbol.for('react.transitional.element')", + "react.production.min", + "react.development", + "__CLIENT_INTERNALS_DO_NOT_USE", + ]; + + public static IReadOnlyList Validate(string wwwrootPath) + { + var violations = new List(); + if (!Directory.Exists(wwwrootPath)) + { + return violations; + } + + var bundleFiles = Directory + .EnumerateFiles(wwwrootPath, "*", SearchOption.AllDirectories) + .Where(f => + ( + f.EndsWith(".js", StringComparison.OrdinalIgnoreCase) + || f.EndsWith(".mjs", StringComparison.OrdinalIgnoreCase) + ) && !f.EndsWith(".map", StringComparison.OrdinalIgnoreCase) + ); + + foreach (var file in bundleFiles) + { + var content = File.ReadAllText(file); + foreach (var marker in InlinedReactMarkers) + { + if (content.Contains(marker, StringComparison.Ordinal)) + { + violations.Add(new ExternalsViolation(file, marker)); + break; + } + } + } + + return violations; + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/NuGetClient.cs b/cli/SimpleModule.Cli/Infrastructure/NuGetClient.cs new file mode 100644 index 00000000..15adeaa4 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/NuGetClient.cs @@ -0,0 +1,171 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Minimal NuGet V3 client: resolves the flat-container resource from a service +/// index, lists versions, and downloads nupkgs. Local directory feeds bypass +/// HTTP entirely via . +/// +public static partial class NuGetClient +{ + private static readonly HttpClient SharedHttpClient = new() + { + Timeout = TimeSpan.FromSeconds(30), + }; + + public static bool IsLocalDirectorySource(string source) => + !source.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + && !source.StartsWith("https://", StringComparison.OrdinalIgnoreCase); + + /// + /// Finds {id}.{version}.nupkg in a local folder feed; without a + /// version the highest one wins. Returns null when absent. + /// + public static string? FindLocalNupkg(string feedDirectory, string packageId, string? version) + { + if (!Directory.Exists(feedDirectory)) + { + return null; + } + + if (version is not null) + { + var exact = Path.Combine(feedDirectory, $"{packageId}.{version}.nupkg"); + return File.Exists(exact) ? exact : null; + } + + var prefix = packageId + "."; + var candidates = new List<(string Path, string Version)>(); + foreach (var file in Directory.EnumerateFiles(feedDirectory, prefix + "*.nupkg")) + { + var name = Path.GetFileNameWithoutExtension(file); + var versionPart = name[prefix.Length..]; + // Reject longer package ids (SimpleModule.X must not match + // SimpleModule.X.Contracts.1.0.0): the remainder must start with a digit. + if (VersionStartRegex().IsMatch(versionPart)) + { + candidates.Add((file, versionPart)); + } + } + + return candidates + .OrderByDescending(c => c.Version, VersionStringComparer.Instance) + .Select(c => c.Path) + .FirstOrDefault(); + } + + public static async Task> GetVersionsAsync( + Uri serviceIndexUrl, + string packageId + ) + { + var baseUrl = await ResolveFlatContainerAsync(serviceIndexUrl); + var url = $"{baseUrl}{packageId.ToLowerInvariant()}/index.json"; + using var response = await SharedHttpClient.GetAsync(new Uri(url)); + if (!response.IsSuccessStatusCode) + { + return []; + } + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return + [ + .. doc + .RootElement.GetProperty("versions") + .EnumerateArray() + .Select(v => v.GetString() ?? ""), + ]; + } + + public static async Task DownloadNupkgAsync( + Uri serviceIndexUrl, + string packageId, + string version, + string destinationPath + ) + { + var baseUrl = await ResolveFlatContainerAsync(serviceIndexUrl); + var idLower = packageId.ToLowerInvariant(); + var versionLower = version.ToLowerInvariant(); + var url = $"{baseUrl}{idLower}/{versionLower}/{idLower}.{versionLower}.nupkg"; + + using var response = await SharedHttpClient.GetAsync(new Uri(url)); + response.EnsureSuccessStatusCode(); + await using var file = File.Create(destinationPath); + await response.Content.CopyToAsync(file); + } + + private static async Task ResolveFlatContainerAsync(Uri serviceIndexUrl) + { + using var response = await SharedHttpClient.GetAsync(serviceIndexUrl); + response.EnsureSuccessStatusCode(); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + foreach (var resource in doc.RootElement.GetProperty("resources").EnumerateArray()) + { + var type = resource.GetProperty("@type").GetString() ?? ""; + if (type.StartsWith("PackageBaseAddress/3.0.0", StringComparison.Ordinal)) + { + var id = resource.GetProperty("@id").GetString() ?? ""; + return id.EndsWith('/') ? id : id + "/"; + } + } + + throw new InvalidOperationException( + $"Registry '{serviceIndexUrl}' exposes no PackageBaseAddress/3.0.0 resource." + ); + } + + /// Orders dotted version strings numerically with release > prerelease. + private sealed class VersionStringComparer : IComparer + { + public static readonly VersionStringComparer Instance = new(); + + public int Compare(string? x, string? y) + { + var (xCore, xPre) = Split(x ?? ""); + var (yCore, yPre) = Split(y ?? ""); + + var xParts = xCore.Split('.'); + var yParts = yCore.Split('.'); + for (var i = 0; i < Math.Max(xParts.Length, yParts.Length); i++) + { + var xNum = i < xParts.Length && int.TryParse(xParts[i], out var xv) ? xv : 0; + var yNum = i < yParts.Length && int.TryParse(yParts[i], out var yv) ? yv : 0; + var byNum = xNum.CompareTo(yNum); + if (byNum != 0) + { + return byNum; + } + } + + if (xPre.Length == 0 && yPre.Length == 0) + { + return 0; + } + + if (xPre.Length == 0) + { + return 1; + } + + if (yPre.Length == 0) + { + return -1; + } + + return string.CompareOrdinal(xPre, yPre); + } + + private static (string Core, string Prerelease) Split(string version) + { + var dash = version.IndexOf('-', StringComparison.Ordinal); + return dash < 0 ? (version, "") : (version[..dash], version[(dash + 1)..]); + } + } + + [GeneratedRegex("^[0-9]")] + private static partial Regex VersionStartRegex(); +} diff --git a/tests/SimpleModule.Cli.Tests/BundleExternalsValidatorTests.cs b/tests/SimpleModule.Cli.Tests/BundleExternalsValidatorTests.cs new file mode 100644 index 00000000..ceedd685 --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/BundleExternalsValidatorTests.cs @@ -0,0 +1,100 @@ +using FluentAssertions; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class BundleExternalsValidatorTests : IDisposable +{ + private readonly string _wwwroot = Path.Combine( + Path.GetTempPath(), + "sm-externals-tests-" + Guid.NewGuid().ToString("N") + ); + + public BundleExternalsValidatorTests() => Directory.CreateDirectory(_wwwroot); + + [Fact] + public void ExternalizedBundle_Passes() + { + Write( + "Module.pages.js", + """ + import { jsx } from "react/jsx-runtime"; + import { usePage } from "@inertiajs/react"; + var pages = { "X/Browse": () => import("./Browse-abc.mjs") }; + export { pages }; + """ + ); + Write( + "Browse-abc.mjs", + """ + import * as React from "react"; + export default function Browse() { return React.createElement("div"); } + """ + ); + + var violations = BundleExternalsValidator.Validate(_wwwroot); + + violations.Should().BeEmpty(); + } + + [Fact] + public void InlinedReact_FailsWithFileAndMarker() + { + Write( + "Module.pages.js", + """ + var ReactSymbol = Symbol.for("react.element"); + function jsxProd(type, config) { return { $$typeof: ReactSymbol }; } + """ + ); + + var violations = BundleExternalsValidator.Validate(_wwwroot); + + violations.Should().ContainSingle(); + violations[0].File.Should().Contain("Module.pages.js"); + violations[0].Marker.Should().Contain("react.element"); + } + + [Theory] + [InlineData("""var x = "react.production.min";""")] + [InlineData( + """var internals = __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;""" + )] + [InlineData("""var s = Symbol.for("react.transitional.element");""")] + public void OtherInlineMarkers_Fail(string content) + { + Write("chunk-x.mjs", content); + + BundleExternalsValidator.Validate(_wwwroot).Should().NotBeEmpty(); + } + + [Fact] + public void EmptyDirectory_PassesWithNoViolations() + { + BundleExternalsValidator.Validate(_wwwroot).Should().BeEmpty(); + } + + [Fact] + public void SourceMaps_AreIgnored() + { + Write( + "Module.pages.js.map", + """{"mappings": "react.element Symbol.for(\"react.element\")"}""" + ); + + BundleExternalsValidator.Validate(_wwwroot).Should().BeEmpty(); + } + + private void Write(string name, string content) => + File.WriteAllText(Path.Combine(_wwwroot, name), content); + + public void Dispose() + { + try + { + Directory.Delete(_wwwroot, recursive: true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} diff --git a/tests/SimpleModule.Cli.Tests/NuGetClientTests.cs b/tests/SimpleModule.Cli.Tests/NuGetClientTests.cs new file mode 100644 index 00000000..7d609d04 --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/NuGetClientTests.cs @@ -0,0 +1,83 @@ +using FluentAssertions; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class NuGetClientTests : IDisposable +{ + private readonly string _feedDir = Path.Combine( + Path.GetTempPath(), + "sm-nugetclient-tests-" + Guid.NewGuid().ToString("N") + ); + + public NuGetClientTests() => Directory.CreateDirectory(_feedDir); + + [Fact] + public void FindLocalNupkg_PicksRequestedVersion() + { + Touch("SimpleModule.X.1.0.0.nupkg"); + Touch("SimpleModule.X.1.2.0.nupkg"); + + var path = NuGetClient.FindLocalNupkg(_feedDir, "SimpleModule.X", "1.0.0"); + + path.Should().EndWith("SimpleModule.X.1.0.0.nupkg"); + } + + [Fact] + public void FindLocalNupkg_NoVersion_PicksHighest() + { + Touch("SimpleModule.X.1.0.0.nupkg"); + Touch("SimpleModule.X.1.10.0.nupkg"); + Touch("SimpleModule.X.1.2.0.nupkg"); + + var path = NuGetClient.FindLocalNupkg(_feedDir, "SimpleModule.X", version: null); + + path.Should().EndWith("SimpleModule.X.1.10.0.nupkg"); + } + + [Fact] + public void FindLocalNupkg_DoesNotMatchLongerPackageIds() + { + Touch("SimpleModule.X.Contracts.1.0.0.nupkg"); + + NuGetClient.FindLocalNupkg(_feedDir, "SimpleModule.X", null).Should().BeNull(); + } + + [Fact] + public void FindLocalNupkg_MissingPackage_ReturnsNull() + { + NuGetClient.FindLocalNupkg(_feedDir, "Nope", null).Should().BeNull(); + } + + [Fact] + public void ExtractVersionFromFileName_HandlesPrerelease() + { + Touch("SimpleModule.X.0.0.39-local.nupkg"); + + var path = NuGetClient.FindLocalNupkg(_feedDir, "SimpleModule.X", "0.0.39-local"); + + path.Should().NotBeNull(); + } + + [Fact] + public void IsLocalDirectorySource_DetectsPathsVsUrls() + { + NuGetClient.IsLocalDirectorySource(_feedDir).Should().BeTrue(); + NuGetClient + .IsLocalDirectorySource("https://api.nuget.org/v3/index.json") + .Should() + .BeFalse(); + } + + private void Touch(string name) => File.WriteAllText(Path.Combine(_feedDir, name), ""); + + public void Dispose() + { + try + { + Directory.Delete(_feedDir, recursive: true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} From 7dc2ff46908deae97cd7d8ff9a250c089f2012de Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 11:53:33 +0200 Subject: [PATCH 14/31] feat(hosting): SIMPLEMODULE_MIGRATE_ONLY hook for deterministic CLI-driven migrations --- .../SimpleModuleHostExtensions.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 8b2e9e45..927aa9cf 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -230,9 +230,14 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) // Database initialization // SQLite (file-based) always needs auto-initialization since the DB file may not exist. // Managed databases (PostgreSQL, SQL Server) skip this in production — apply migrations externally. + // SIMPLEMODULE_MIGRATE_ONLY=1 is the CLI's migration entry point (`sm add`/`sm upgrade`): + // it forces database initialization regardless of environment, then exits without + // serving traffic — the deterministic migration hook for installed packaged modules. + var migrateOnly = Environment.GetEnvironmentVariable("SIMPLEMODULE_MIGRATE_ONLY") == "1"; var smOptions = app.Services.GetRequiredService(); if ( - !app.Environment.IsProduction() + migrateOnly + || !app.Environment.IsProduction() || smOptions.DatabaseProvider == DatabaseProvider.Sqlite ) { @@ -269,6 +274,14 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) } } + if (migrateOnly) + { + Console.WriteLine( + "SIMPLEMODULE_MIGRATE_ONLY=1: database initialization complete; exiting without starting the server." + ); + Environment.Exit(0); + } + app.UseForwardedHeaders(); var errorHtmlPath = Path.Combine(app.Environment.WebRootPath, "error.html"); From 50dbbdf06dac27eaf347f80369bd7e351a09e414 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 11:54:04 +0200 Subject: [PATCH 15/31] feat(hosting): SIMPLEMODULE_MIGRATE_ONLY hook for deterministic CLI-driven migrations --- framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 927aa9cf..6e34bdf9 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -276,7 +276,7 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) if (migrateOnly) { - Console.WriteLine( + app.Logger.LogInformation( "SIMPLEMODULE_MIGRATE_ONLY=1: database initialization complete; exiting without starting the server." ); Environment.Exit(0); From 216ee9541315d7b13569cf570019763db5614138 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 11:54:23 +0200 Subject: [PATCH 16/31] feat(hosting): SIMPLEMODULE_MIGRATE_ONLY hook for deterministic CLI-driven migrations --- framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 6e34bdf9..119569a5 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Constants; From 4c5c0f6542d2f479469cbc405c39de2cffc67f91 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 11:59:32 +0200 Subject: [PATCH 17/31] =?UTF-8?q?feat(cli):=20sm=20pack=20=E2=80=94=20buil?= =?UTF-8?q?d,=20validate=20and=20pack=20modules=20into=20nupkgs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../Commands/Pack/PackCommand.cs | 274 ++++++++++++++++++ .../Commands/Pack/PackPipeline.cs | 161 ++++++++++ .../Infrastructure/ProcessRunner.cs | 53 ++++ cli/SimpleModule.Cli/Program.cs | 9 + .../PackPipelineTests.cs | 125 ++++++++ 6 files changed, 623 insertions(+) create mode 100644 cli/SimpleModule.Cli/Commands/Pack/PackCommand.cs create mode 100644 cli/SimpleModule.Cli/Commands/Pack/PackPipeline.cs create mode 100644 cli/SimpleModule.Cli/Infrastructure/ProcessRunner.cs create mode 100644 tests/SimpleModule.Cli.Tests/PackPipelineTests.cs diff --git a/.gitignore b/.gitignore index f3c169dd..c9ae4714 100644 --- a/.gitignore +++ b/.gitignore @@ -451,3 +451,4 @@ baseline/ # Verification artifacts .verify/ .qa/ +module-manifest.json diff --git a/cli/SimpleModule.Cli/Commands/Pack/PackCommand.cs b/cli/SimpleModule.Cli/Commands/Pack/PackCommand.cs new file mode 100644 index 00000000..8c2d3f0a --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Pack/PackCommand.cs @@ -0,0 +1,274 @@ +using System.ComponentModel; +using SimpleModule.Cli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Pack; + +public sealed class PackSettings : CommandSettings +{ + [CommandArgument(0, "[module-path]")] + [Description("Path to the module directory (defaults to the current directory).")] + public string? ModulePath { get; init; } + + [CommandOption("-o|--output")] + [Description("Output directory for the nupkg(s). Default: ./artifacts/packages")] + public string? Output { get; init; } + + [CommandOption("--version")] + [Description("Package version override (passed as -p:Version to build and pack).")] + public string? Version { get; init; } + + [CommandOption("--skip-tests")] + [Description("Skip running the module's test project.")] + public bool SkipTests { get; init; } + + [CommandOption("-c|--configuration")] + [Description("Build configuration. Default: Release")] + public string Configuration { get; init; } = "Release"; +} + +/// +/// Builds, validates and packs a module into a standard nupkg: +/// production frontend build → externals validation → dotnet build → tests → +/// manifest validation → dotnet pack (module + contracts). Fails closed at the +/// first violated step. +/// +public sealed class PackCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, PackSettings settings) + { + var moduleDir = Path.GetFullPath(settings.ModulePath ?? "."); + var (projects, resolveError) = PackPipeline.ResolveModuleProjects(moduleDir); + if (projects is null) + { + return Fail(resolveError!); + } + + var outputDir = Path.GetFullPath( + settings.Output + ?? Path.Combine( + SolutionContext.Discover()?.RootPath ?? moduleDir, + "artifacts", + "packages" + ) + ); + Directory.CreateDirectory(outputDir); + + AnsiConsole.MarkupLine( + $"Packing module [green]{Markup.Escape(projects.AssemblyName)}[/] from [blue]{Markup.Escape(moduleDir)}[/]" + ); + + // 1. Production frontend build (when the module has one) + var implDir = projects.ImplDirectory; + if (File.Exists(Path.Combine(implDir, "package.json"))) + { + if (!HasNodeModules(implDir)) + { + return Fail( + "package.json found but no node_modules is available. " + + "Run 'npm install' before packing so the frontend bundle can be built." + ); + } + + AnsiConsole.MarkupLine("[dim]→ building frontend (vite, production)...[/]"); + var vite = await ProcessRunner.RunAsync( + "npx", + ["vite", "build", "--configLoader", "runner"], + implDir + ); + if (!vite.Success) + { + return Fail("Frontend build failed:\n" + Tail(vite.Error, vite.Output)); + } + + // 2. Externals validation — fail closed on inlined React + var violations = BundleExternalsValidator.Validate(Path.Combine(implDir, "wwwroot")); + if (violations.Count > 0) + { + foreach (var violation in violations) + { + AnsiConsole.MarkupLine( + $"[red]✗ {Markup.Escape(Path.GetFileName(violation.File))} inlines a host-provided library (marker: {Markup.Escape(violation.Marker)})[/]" + ); + } + + return Fail( + "Module bundles must externalize react, react-dom, react/jsx-runtime and " + + "@inertiajs/react — they are provided by the host. Check the module's " + + "vite.config.ts uses defineModuleConfig from @simplemodule/client." + ); + } + } + + var versionProps = settings.Version is null + ? Array.Empty() + : [$"-p:Version={settings.Version}"]; + + // 3. Backend build + AnsiConsole.MarkupLine("[dim]→ dotnet build...[/]"); + var build = await ProcessRunner.RunAsync( + "dotnet", + ["build", projects.ImplCsproj, "-c", settings.Configuration, .. versionProps], + implDir + ); + if (!build.Success) + { + return Fail("dotnet build failed:\n" + Tail(build.Error, build.Output)); + } + + // 4. Tests + if (!settings.SkipTests && projects.TestsCsproj is not null) + { + AnsiConsole.MarkupLine("[dim]→ dotnet test...[/]"); + var test = await ProcessRunner.RunAsync( + "dotnet", + ["test", projects.TestsCsproj, "-c", settings.Configuration, .. versionProps], + implDir + ); + if (!test.Success) + { + return Fail("Module tests failed:\n" + Tail(test.Error, test.Output)); + } + } + else if (!settings.SkipTests) + { + AnsiConsole.MarkupLine("[yellow]! no test project found — skipping tests[/]"); + } + + // 5. Manifest validation from the built assembly + var builtDll = FindBuiltAssembly(implDir, settings.Configuration, projects.AssemblyName); + if (builtDll is null) + { + return Fail( + $"Could not locate the built assembly {projects.AssemblyName}.dll under bin/{settings.Configuration}." + ); + } + + var manifest = AssemblyManifestReader.TryRead(builtDll); + var manifestErrors = PackPipeline.ValidateManifest( + manifest, + projects.AssemblyName, + Path.Combine(implDir, "wwwroot") + ); + if (manifestErrors.Count > 0) + { + foreach (var error in manifestErrors) + { + AnsiConsole.MarkupLine($"[red]✗ {Markup.Escape(error)}[/]"); + } + + return Fail("Module manifest validation failed."); + } + + // 6. Write module-manifest.json next to the project so the injected + // targets pack it into the nupkg root for registry/tooling consumption. + var manifestJson = AssemblyManifestReader.TryReadJson(builtDll)!; + await File.WriteAllTextAsync(Path.Combine(implDir, "module-manifest.json"), manifestJson); + + var targetsPath = Path.Combine( + Path.GetTempPath(), + "sm-pack-" + Guid.NewGuid().ToString("N") + ".targets" + ); + await File.WriteAllTextAsync(targetsPath, PackPipeline.PackTargetsContent); + + try + { + // 7. Pack module (+ contracts) + var packTargets = new List { projects.ImplCsproj }; + if (projects.ContractsCsproj is not null) + { + packTargets.Add(projects.ContractsCsproj); + } + + foreach (var csproj in packTargets) + { + AnsiConsole.MarkupLine( + $"[dim]→ dotnet pack {Markup.Escape(Path.GetFileName(csproj))}...[/]" + ); + var pack = await ProcessRunner.RunAsync( + "dotnet", + [ + "pack", + csproj, + "-c", + settings.Configuration, + "-o", + outputDir, + $"-p:CustomAfterMicrosoftCommonTargets={targetsPath}", + .. versionProps, + ], + implDir + ); + if (!pack.Success) + { + return Fail("dotnet pack failed:\n" + Tail(pack.Error, pack.Output)); + } + } + } + finally + { + try + { + File.Delete(targetsPath); + } + catch (IOException) { } + } + + AnsiConsole.MarkupLine( + $"[green]✓ Packed {Markup.Escape(manifest!.DisplayName)} {Markup.Escape(manifest.Version)}[/] → [blue]{Markup.Escape(outputDir)}[/]" + ); + AnsiConsole.MarkupLine( + $"[dim] schema: {Markup.Escape(manifest.Schema)} permissions: {manifest.Permissions.Count} pages: {manifest.Pages.Count} frameworkCompat: {Markup.Escape(manifest.FrameworkCompat)}[/]" + ); + + return 0; + } + + private static bool HasNodeModules(string implDir) + { + var dir = new DirectoryInfo(implDir); + while (dir is not null) + { + if (Directory.Exists(Path.Combine(dir.FullName, "node_modules"))) + { + return true; + } + + dir = dir.Parent; + } + + return false; + } + + private static string? FindBuiltAssembly( + string implDir, + string configuration, + string assemblyName + ) + { + var binDir = Path.Combine(implDir, "bin", configuration); + if (!Directory.Exists(binDir)) + { + return null; + } + + return Directory + .EnumerateFiles(binDir, assemblyName + ".dll", SearchOption.AllDirectories) + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + } + + private static string Tail(string error, string output) + { + var text = string.IsNullOrWhiteSpace(error) ? output : error; + var lines = text.Split('\n'); + return string.Join('\n', lines.TakeLast(25)).Trim(); + } + + private static int Fail(string message) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(message)}[/]"); + return 1; + } +} diff --git a/cli/SimpleModule.Cli/Commands/Pack/PackPipeline.cs b/cli/SimpleModule.Cli/Commands/Pack/PackPipeline.cs new file mode 100644 index 00000000..dce10636 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Pack/PackPipeline.cs @@ -0,0 +1,161 @@ +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Commands.Pack; + +public sealed record ModuleProjectSet( + string ImplCsproj, + string? ContractsCsproj, + string? TestsCsproj +) +{ + public string ImplDirectory => Path.GetDirectoryName(ImplCsproj)!; + public string AssemblyName => Path.GetFileNameWithoutExtension(ImplCsproj); +} + +/// +/// Pure decision logic behind sm pack: project resolution and manifest +/// validation. Subprocess orchestration lives in . +/// +public static class PackPipeline +{ + /// + /// MSBuild targets injected via -p:CustomAfterMicrosoftCommonTargets so every + /// packed module — in-repo or downstream — ships module-manifest.json, is + /// packable, and carries the simplemodule-module tag, without editing the + /// user's project files. + /// + public const string PackTargetsContent = """ + + + true + $(PackageTags);simplemodule-module + + + + + + + + """; + + /// + /// Locates the implementation, contracts and test projects under a module + /// directory (works for both modules/X roots and the impl project dir + /// itself). Errors are returned, not thrown — pack fails closed with them. + /// + public static (ModuleProjectSet? Projects, string? Error) ResolveModuleProjects( + string moduleDirectory + ) + { + if (!Directory.Exists(moduleDirectory)) + { + return (null, $"Module directory '{moduleDirectory}' does not exist."); + } + + var csprojs = Directory + .EnumerateFiles(moduleDirectory, "*.csproj", SearchOption.AllDirectories) + .Where(p => + !p.Contains( + $"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", + StringComparison.Ordinal + ) + ) + .ToList(); + + if (csprojs.Count == 0) + { + return (null, $"No .csproj files found under '{moduleDirectory}'."); + } + + var contracts = csprojs + .Where(p => + Path.GetFileNameWithoutExtension(p).EndsWith(".Contracts", StringComparison.Ordinal) + ) + .ToList(); + var tests = csprojs + .Where(p => + Path.GetFileNameWithoutExtension(p).EndsWith(".Tests", StringComparison.Ordinal) + ) + .ToList(); + var impls = csprojs.Except(contracts).Except(tests).ToList(); + + if (impls.Count == 0) + { + return ( + null, + $"No module implementation project found under '{moduleDirectory}' " + + "(only Contracts/Tests projects present)." + ); + } + + if (impls.Count > 1) + { + return ( + null, + $"Found {impls.Count} candidate implementation projects under '{moduleDirectory}': " + + string.Join(", ", impls.Select(Path.GetFileName)) + + ". Point sm pack at a single module directory." + ); + } + + return ( + new ModuleProjectSet(impls[0], contracts.FirstOrDefault(), tests.FirstOrDefault()), + null + ); + } + + /// Validates the built assembly's manifest before packing. + public static IReadOnlyList ValidateManifest( + ModuleManifestData? manifest, + string expectedAssemblyName, + string wwwrootDirectory + ) + { + var errors = new List(); + if (manifest is null) + { + errors.Add( + "The built assembly carries no module manifest. Ensure the project builds with " + + "SimpleModuleProjectKind=Module and references the SimpleModule.Generator analyzer." + ); + return errors; + } + + if (manifest.SchemaVersion != 1) + { + errors.Add( + $"Manifest schemaVersion {manifest.SchemaVersion} is not supported by this CLI (expected 1)." + ); + } + + if (!string.Equals(manifest.Id, expectedAssemblyName, StringComparison.Ordinal)) + { + errors.Add( + $"Manifest id '{manifest.Id}' does not match the assembly name '{expectedAssemblyName}'." + ); + } + + if (string.IsNullOrEmpty(manifest.Name)) + { + errors.Add("Manifest has no module name — is the [Module] attribute present?"); + } + + if (!string.IsNullOrEmpty(manifest.FrontendEntry)) + { + var bundleFileName = manifest.FrontendEntry.Split('/').Last(); + var bundlePath = Path.Combine(wwwrootDirectory, bundleFileName); + if (!File.Exists(bundlePath)) + { + errors.Add( + $"Manifest declares frontend entry '{manifest.FrontendEntry}' but " + + $"'{bundlePath}' does not exist. Run the module's Vite build." + ); + } + } + + return errors; + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/ProcessRunner.cs b/cli/SimpleModule.Cli/Infrastructure/ProcessRunner.cs new file mode 100644 index 00000000..5d835197 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/ProcessRunner.cs @@ -0,0 +1,53 @@ +using System.Diagnostics; + +namespace SimpleModule.Cli.Infrastructure; + +public sealed record ProcessResult(int ExitCode, string Output, string Error) +{ + public bool Success => ExitCode == 0; +} + +/// Runs external tools (dotnet, npx) capturing output. +public static class ProcessRunner +{ + public static async Task RunAsync( + string fileName, + IReadOnlyList arguments, + string? workingDirectory = null, + IReadOnlyDictionary? environment = null + ) + { + var psi = new ProcessStartInfo + { + FileName = fileName, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory, + }; + foreach (var argument in arguments) + { + psi.ArgumentList.Add(argument); + } + + if (environment is not null) + { + foreach (var kvp in environment) + { + psi.Environment[kvp.Key] = kvp.Value; + } + } + + using var process = new Process { StartInfo = psi }; + process.Start(); + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync().ConfigureAwait(false); + + return new ProcessResult( + process.ExitCode, + await outputTask.ConfigureAwait(false), + await errorTask.ConfigureAwait(false) + ); + } +} diff --git a/cli/SimpleModule.Cli/Program.cs b/cli/SimpleModule.Cli/Program.cs index 00b5d7c0..19b4d017 100644 --- a/cli/SimpleModule.Cli/Program.cs +++ b/cli/SimpleModule.Cli/Program.cs @@ -5,6 +5,7 @@ using SimpleModule.Cli.Commands.List; using SimpleModule.Cli.Commands.Maintenance; using SimpleModule.Cli.Commands.New; +using SimpleModule.Cli.Commands.Pack; using SimpleModule.Cli.Commands.Skill; using SimpleModule.Cli.Commands.Version; using Spectre.Console.Cli; @@ -59,6 +60,14 @@ .AddCommand("install") .WithDescription("Install a SimpleModule package from NuGet"); + config + .AddCommand("pack") + .WithDescription( + "Build, validate and pack a module into a distributable nupkg (frontend build, externals check, tests, manifest validation)" + ) + .WithExample("pack", "modules/Products") + .WithExample("pack", "modules/Products", "--version", "1.2.0", "--output", "./feed"); + config .AddCommand("doctor") .WithDescription("Validate project structure and conventions"); diff --git a/tests/SimpleModule.Cli.Tests/PackPipelineTests.cs b/tests/SimpleModule.Cli.Tests/PackPipelineTests.cs new file mode 100644 index 00000000..4e131485 --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/PackPipelineTests.cs @@ -0,0 +1,125 @@ +using FluentAssertions; +using SimpleModule.Cli.Commands.Pack; +using SimpleModule.Cli.Infrastructure; + +namespace SimpleModule.Cli.Tests; + +public sealed class PackPipelineTests : IDisposable +{ + private readonly string _tempDir = Path.Combine( + Path.GetTempPath(), + "sm-pack-tests-" + Guid.NewGuid().ToString("N") + ); + + public PackPipelineTests() => Directory.CreateDirectory(_tempDir); + + [Fact] + public void ResolveModuleProjects_FindsImplContractsAndTests() + { + Touch("src/SimpleModule.X/SimpleModule.X.csproj"); + Touch("src/SimpleModule.X.Contracts/SimpleModule.X.Contracts.csproj"); + Touch("tests/SimpleModule.X.Tests/SimpleModule.X.Tests.csproj"); + + var (projects, error) = PackPipeline.ResolveModuleProjects(_tempDir); + + error.Should().BeNull(); + projects!.AssemblyName.Should().Be("SimpleModule.X"); + projects.ContractsCsproj.Should().EndWith("SimpleModule.X.Contracts.csproj"); + projects.TestsCsproj.Should().EndWith("SimpleModule.X.Tests.csproj"); + } + + [Fact] + public void ResolveModuleProjects_IgnoresObjDirectories() + { + Touch("src/SimpleModule.X/SimpleModule.X.csproj"); + Touch("src/SimpleModule.X/obj/Debug/SimpleModule.X.Stale.csproj"); + + var (projects, error) = PackPipeline.ResolveModuleProjects(_tempDir); + + error.Should().BeNull(); + projects!.AssemblyName.Should().Be("SimpleModule.X"); + } + + [Fact] + public void ResolveModuleProjects_FailsOnMultipleImplProjects() + { + Touch("src/SimpleModule.X/SimpleModule.X.csproj"); + Touch("src/SimpleModule.Y/SimpleModule.Y.csproj"); + + var (projects, error) = PackPipeline.ResolveModuleProjects(_tempDir); + + projects.Should().BeNull(); + error.Should().Contain("2 candidate"); + } + + [Fact] + public void ResolveModuleProjects_FailsOnMissingDirectory() + { + var (projects, error) = PackPipeline.ResolveModuleProjects( + Path.Combine(_tempDir, "missing") + ); + + projects.Should().BeNull(); + error.Should().Contain("does not exist"); + } + + [Fact] + public void ValidateManifest_NullManifest_ExplainsGeneratorWiring() + { + var errors = PackPipeline.ValidateManifest(null, "SimpleModule.X", _tempDir); + + errors.Should().ContainSingle().Which.Should().Contain("SimpleModuleProjectKind"); + } + + [Fact] + public void ValidateManifest_IdMismatch_Fails() + { + var manifest = ModuleManifestData.TryParse( + """{"schemaVersion":1,"id":"Wrong.Id","name":"X"}""" + ); + + var errors = PackPipeline.ValidateManifest(manifest, "SimpleModule.X", _tempDir); + + errors.Should().Contain(e => e.Contains("does not match the assembly name")); + } + + [Fact] + public void ValidateManifest_MissingFrontendBundle_Fails() + { + var manifest = ModuleManifestData.TryParse( + """{"schemaVersion":1,"id":"SimpleModule.X","name":"X","frontendEntry":"_content/SimpleModule.X/SimpleModule.X.pages.js"}""" + ); + + var errors = PackPipeline.ValidateManifest(manifest, "SimpleModule.X", _tempDir); + + errors.Should().Contain(e => e.Contains("does not exist")); + } + + [Fact] + public void ValidateManifest_ValidManifestWithExistingBundle_Passes() + { + File.WriteAllText(Path.Combine(_tempDir, "SimpleModule.X.pages.js"), "export {};"); + var manifest = ModuleManifestData.TryParse( + """{"schemaVersion":1,"id":"SimpleModule.X","name":"X","frontendEntry":"_content/SimpleModule.X/SimpleModule.X.pages.js"}""" + ); + + PackPipeline.ValidateManifest(manifest, "SimpleModule.X", _tempDir).Should().BeEmpty(); + } + + private void Touch(string relativePath) + { + var path = Path.Combine(_tempDir, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, ""); + } + + public void Dispose() + { + try + { + Directory.Delete(_tempDir, recursive: true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} From c1de98788fc4a1e7c86833ae1bd8b6550c187e3f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 12:02:17 +0200 Subject: [PATCH 18/31] feat(cli): sm add, sm remove and packaged-modules section in sm list --- .../Commands/Add/AddCommand.cs | 241 ++++++++++++++++++ .../Commands/Doctor/DoctorCommand.cs | 54 ++-- .../Commands/List/ListCommand.cs | 98 +++++-- .../Commands/Remove/RemoveCommand.cs | 84 ++++++ .../Infrastructure/GlobalPackagesCache.cs | 76 ++++++ cli/SimpleModule.Cli/Program.cs | 17 ++ 6 files changed, 532 insertions(+), 38 deletions(-) create mode 100644 cli/SimpleModule.Cli/Commands/Add/AddCommand.cs create mode 100644 cli/SimpleModule.Cli/Commands/Remove/RemoveCommand.cs create mode 100644 cli/SimpleModule.Cli/Infrastructure/GlobalPackagesCache.cs diff --git a/cli/SimpleModule.Cli/Commands/Add/AddCommand.cs b/cli/SimpleModule.Cli/Commands/Add/AddCommand.cs new file mode 100644 index 00000000..242ef619 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Add/AddCommand.cs @@ -0,0 +1,241 @@ +using System.ComponentModel; +using SimpleModule.Cli.Commands.Doctor; +using SimpleModule.Cli.Commands.Doctor.Checks; +using SimpleModule.Cli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Add; + +public sealed class AddSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("NuGet package id of the module (e.g. SimpleModule.Products).")] + public string PackageId { get; init; } = ""; + + [CommandOption("--version")] + [Description("Exact package version. Default: highest available.")] + public string? Version { get; init; } + + [CommandOption("--source")] + [Description( + "Package source: a local folder feed or a NuGet V3 service index URL. Default: the registry from sm.json (nuget.org)." + )] + public string? Source { get; init; } + + [CommandOption("--skip-migrations")] + [Description("Do not run the host in migrate-only mode after install.")] + public bool SkipMigrations { get; init; } + + [CommandOption("--skip-doctor")] + [Description("Do not run sm doctor after install.")] + public bool SkipDoctor { get; init; } +} + +/// +/// Installs a packaged module into the host: resolves the nupkg, validates the +/// module manifest and framework compatibility BEFORE touching any file, wires +/// the (CPM-aware) package reference, restores + builds, applies module +/// migrations via the SIMPLEMODULE_MIGRATE_ONLY hook, then runs doctor. +/// +public sealed class AddCommand : AsyncCommand +{ + public override async Task ExecuteAsync(CommandContext context, AddSettings settings) + { + var solution = SolutionContext.Discover(); + if (solution is null) + { + return Fail( + "No .slnx file found. Run this command from inside a SimpleModule project." + ); + } + + // 1. Resolve the package source and obtain the nupkg + var source = settings.Source ?? SmConfig.Load(solution.RootPath).Registry; + var isLocalSource = NuGetClient.IsLocalDirectorySource(source); + + string? nupkgPath; + string resolvedVersion; + if (isLocalSource) + { + var feedDir = Path.GetFullPath(source); + nupkgPath = NuGetClient.FindLocalNupkg(feedDir, settings.PackageId, settings.Version); + if (nupkgPath is null) + { + return Fail( + $"Package {settings.PackageId}" + + (settings.Version is null ? "" : $" {settings.Version}") + + $" not found in local feed '{feedDir}'." + ); + } + + resolvedVersion = Path.GetFileNameWithoutExtension(nupkgPath)[ + (settings.PackageId.Length + 1).. + ]; + } + else + { + var serviceIndex = new Uri(source); + var versions = await NuGetClient.GetVersionsAsync(serviceIndex, settings.PackageId); + if (versions.Count == 0) + { + return Fail($"Package {settings.PackageId} not found on '{source}'."); + } + + resolvedVersion = settings.Version ?? versions[^1]; + if (!versions.Contains(resolvedVersion)) + { + return Fail( + $"Version {resolvedVersion} of {settings.PackageId} not found on '{source}'. " + + $"Available: {string.Join(", ", versions.TakeLast(8))}" + ); + } + + nupkgPath = Path.Combine( + Path.GetTempPath(), + $"{settings.PackageId}.{resolvedVersion}.nupkg" + ); + AnsiConsole.MarkupLine( + $"[dim]→ downloading {Markup.Escape(settings.PackageId)} {Markup.Escape(resolvedVersion)}...[/]" + ); + await NuGetClient.DownloadNupkgAsync( + serviceIndex, + settings.PackageId, + resolvedVersion, + nupkgPath + ); + } + + // 2. Manifest — required for module packages + var manifest = NupkgManifestReader.TryRead(nupkgPath, settings.PackageId); + if (manifest is null) + { + return Fail( + $"{settings.PackageId} carries no SimpleModule module manifest — it is not a " + + "SimpleModule module package. Use 'sm install' for plain NuGet packages." + ); + } + + // 3. Framework compatibility gate BEFORE any file change + var hostVersion = HostFrameworkVersionResolver.Resolve(solution.RootPath); + if (hostVersion is null) + { + AnsiConsole.MarkupLine( + "[yellow]! could not determine the host's SimpleModule.Core version — skipping compat check[/]" + ); + } + else + { + var compat = FrameworkCompatChecker.Check(manifest.FrameworkCompat, hostVersion); + if (!compat.Compatible) + { + return Fail( + $"Refusing to install {settings.PackageId} {resolvedVersion}: {compat.Reason}" + ); + } + + AnsiConsole.MarkupLine($"[dim]✓ {Markup.Escape(compat.Reason)}[/]"); + } + + // 4. Local feeds must be resolvable at restore time + if (isLocalSource) + { + NuGetConfigManipulator.EnsureLocalSource(solution.RootPath, Path.GetFullPath(source)); + } + + // 5. Wire the package reference (CPM-aware) + PackageReferenceManipulator.AddPackage( + solution.ApiCsprojPath, + solution.RootPath, + settings.PackageId, + resolvedVersion + ); + AnsiConsole.MarkupLine( + $"[green]✓ added {Markup.Escape(settings.PackageId)} {Markup.Escape(resolvedVersion)} to {Markup.Escape(Path.GetFileName(solution.ApiCsprojPath))}[/]" + ); + + // 6. Restore + build + AnsiConsole.MarkupLine("[dim]→ dotnet restore + build...[/]"); + var build = await ProcessRunner.RunAsync( + "dotnet", + ["build", solution.ApiCsprojPath], + solution.RootPath + ); + if (!build.Success) + { + return Fail( + "Build failed after adding the package — the reference was left in place for inspection:\n" + + Tail(build.Error, build.Output) + ); + } + + // 7. Apply module migrations deterministically + if (!settings.SkipMigrations && manifest.HasDbContext) + { + AnsiConsole.MarkupLine("[dim]→ applying module migrations (migrate-only run)...[/]"); + var migrate = await ProcessRunner.RunAsync( + "dotnet", + ["run", "--project", Path.GetDirectoryName(solution.ApiCsprojPath)!, "--no-build"], + solution.RootPath, + new Dictionary { ["SIMPLEMODULE_MIGRATE_ONLY"] = "1" } + ); + if (!migrate.Success) + { + return Fail( + "Module migration run failed (the package reference is in place; fix the " + + "database issue and re-run with SIMPLEMODULE_MIGRATE_ONLY=1):\n" + + Tail(migrate.Error, migrate.Output) + ); + } + + AnsiConsole.MarkupLine("[green]✓ database initialized[/]"); + } + + PrintSummary(manifest, resolvedVersion); + + // 8. Doctor + if (!settings.SkipDoctor) + { + AnsiConsole.Write(new Rule("[blue]sm doctor[/]").LeftJustified()); + var results = DoctorCommand.RunChecks(solution); + DoctorCommand.RenderResults(results); + if (results.Exists(r => r.Status == CheckStatus.Fail)) + { + AnsiConsole.MarkupLine( + "[yellow]! doctor reported failures — run 'sm doctor --fix'[/]" + ); + } + } + + return 0; + } + + private static void PrintSummary(ModuleManifestData manifest, string version) + { + AnsiConsole.MarkupLine( + $"[green]✓ Installed {Markup.Escape(manifest.DisplayName)}[/] [dim]({Markup.Escape(manifest.Id)} {Markup.Escape(version)})[/]" + ); + AnsiConsole.MarkupLine( + $"[dim] schema: {Markup.Escape(manifest.Schema)} permissions: {manifest.Permissions.Count}" + + $" pages: {manifest.Pages.Count}" + + ( + manifest.FrontendEntry is null + ? " (backend-only)" + : $" frontend: {Markup.Escape(manifest.FrontendEntry)}" + ) + + "[/]" + ); + } + + private static string Tail(string error, string output) + { + var text = string.IsNullOrWhiteSpace(error) ? output : error; + return string.Join('\n', text.Split('\n').TakeLast(25)).Trim(); + } + + private static int Fail(string message) + { + AnsiConsole.MarkupLine($"[red]{Markup.Escape(message)}[/]"); + return 1; + } +} diff --git a/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs b/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs index 99faef6b..d03f379c 100644 --- a/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs +++ b/cli/SimpleModule.Cli/Commands/Doctor/DoctorCommand.cs @@ -20,27 +20,8 @@ public override int Execute(CommandContext context, DoctorSettings settings) AnsiConsole.Write(new Rule("[blue]Project health check[/]").LeftJustified()); - IDoctorCheck[] checks = - [ - new SolutionStructureCheck(), - new ProjectReferenceCheck(), - new SlnxEntriesCheck(), - new CsprojConventionCheck(), - new ContractsIsolationCheck(), - new ModulePatternCheck(), - new ModuleAttributeCheck(), - new ViewEndpointNamingCheck(), - new PagesRegistryCheck(), - new ViteConfigCheck(), - new PackageJsonCheck(), - new NpmWorkspaceCheck(), - ]; - - var results = new List(); - foreach (var check in checks) - { - results.AddRange(check.Run(solution)); - } + var checks = CreateChecks(); + var results = RunChecks(solution); var fixedCount = 0; if (settings.Fix) @@ -69,7 +50,36 @@ public override int Execute(CommandContext context, DoctorSettings settings) return failCount > 0 ? 1 : 0; } - private static void RenderResults(IReadOnlyList results) + private static IDoctorCheck[] CreateChecks() => + [ + new SolutionStructureCheck(), + new ProjectReferenceCheck(), + new SlnxEntriesCheck(), + new CsprojConventionCheck(), + new ContractsIsolationCheck(), + new ModulePatternCheck(), + new ModuleAttributeCheck(), + new ViewEndpointNamingCheck(), + new PagesRegistryCheck(), + new ViteConfigCheck(), + new PackageJsonCheck(), + new NpmWorkspaceCheck(), + ]; + + /// Runs all doctor checks; reused by other commands (e.g. sm add). + public static List RunChecks(SolutionContext solution) + { + var results = new List(); + foreach (var check in CreateChecks()) + { + results.AddRange(check.Run(solution)); + } + + return results; + } + + /// Renders check results as the standard doctor table. + public static void RenderResults(IReadOnlyList results) { // Failures first so they're the first thing the user sees, then warnings, // then passes. Preserve discovery order within each status bucket. diff --git a/cli/SimpleModule.Cli/Commands/List/ListCommand.cs b/cli/SimpleModule.Cli/Commands/List/ListCommand.cs index 8f3e9145..31f52ba7 100644 --- a/cli/SimpleModule.Cli/Commands/List/ListCommand.cs +++ b/cli/SimpleModule.Cli/Commands/List/ListCommand.cs @@ -32,38 +32,104 @@ public override int Execute(CommandContext context, ListSettings settings) return 1; } - if (solution.ExistingModules.Count == 0) + var packagedModules = CollectPackagedModules(solution); + + if (solution.ExistingModules.Count == 0 && packagedModules.Count == 0) { AnsiConsole.MarkupLine( - "[yellow]No modules found.[/] Create one with [green]sm new module [/]." + "[yellow]No modules found.[/] Create one with [green]sm new module [/] " + + "or install one with [green]sm add [/]." ); return 0; } - var table = new Table().RoundedBorder(); - table.AddColumn("Module"); - table.AddColumn("Route prefix"); - table.AddColumn(new TableColumn("Endpoints").RightAligned()); + if (solution.ExistingModules.Count > 0) + { + var table = new Table().RoundedBorder(); + table.AddColumn("Module"); + table.AddColumn("Route prefix"); + table.AddColumn(new TableColumn("Endpoints").RightAligned()); + + foreach (var module in solution.ExistingModules) + { + var routePrefix = ReadRoutePrefix(solution, module); + var endpointCount = CountEndpoints(solution, module); + + table.AddRow( + $"[green]{Markup.Escape(module)}[/]", + Markup.Escape(routePrefix ?? "—"), + endpointCount.ToString(System.Globalization.CultureInfo.InvariantCulture) + ); + } - foreach (var module in solution.ExistingModules) + AnsiConsole.Write(table); + } + + if (packagedModules.Count > 0) { - var routePrefix = ReadRoutePrefix(solution, module); - var endpointCount = CountEndpoints(solution, module); + var hostVersion = HostFrameworkVersionResolver.Resolve(solution.RootPath); + var packagedTable = new Table().RoundedBorder(); + packagedTable.AddColumn("Installed package"); + packagedTable.AddColumn("Version"); + packagedTable.AddColumn("Module"); + packagedTable.AddColumn("Framework compat"); + + foreach (var (reference, manifest) in packagedModules) + { + var compatCell = "[dim]unknown[/]"; + if (hostVersion is not null) + { + var compat = FrameworkCompatChecker.Check( + manifest.FrameworkCompat, + hostVersion + ); + compatCell = compat.Compatible + ? $"[green]✓[/] {Markup.Escape(manifest.FrameworkCompat)}" + : $"[red]✗[/] {Markup.Escape(manifest.FrameworkCompat)}"; + } + + packagedTable.AddRow( + $"[blue]{Markup.Escape(reference.Id)}[/]", + Markup.Escape(reference.Version ?? "?"), + Markup.Escape(manifest.DisplayName), + compatCell + ); + } - table.AddRow( - $"[green]{Markup.Escape(module)}[/]", - Markup.Escape(routePrefix ?? "—"), - endpointCount.ToString(System.Globalization.CultureInfo.InvariantCulture) - ); + AnsiConsole.Write(packagedTable); } - AnsiConsole.Write(table); AnsiConsole.MarkupLine( - $"\n[dim]{solution.ExistingModules.Count} module(s) in {Markup.Escape(solution.RootPath)}[/]" + $"\n[dim]{solution.ExistingModules.Count} source module(s), {packagedModules.Count} packaged module(s) in {Markup.Escape(solution.RootPath)}[/]" ); return 0; } + private static List<( + PackageReferenceEntry Reference, + ModuleManifestData Manifest + )> CollectPackagedModules(SolutionContext solution) + { + var packaged = new List<(PackageReferenceEntry, ModuleManifestData)>(); + foreach ( + var reference in PackageReferenceManipulator.GetPackageReferences( + solution.ApiCsprojPath, + solution.RootPath + ) + ) + { + // Only packages carrying a module manifest are SimpleModule modules; + // framework packages (SimpleModule.Hosting, ...) have none. + var manifest = GlobalPackagesCache.TryReadManifest(reference.Id, reference.Version); + if (manifest is not null) + { + packaged.Add((reference, manifest)); + } + } + + return packaged; + } + private static string? ReadRoutePrefix(SolutionContext solution, string module) { var moduleClassPath = Path.Combine( diff --git a/cli/SimpleModule.Cli/Commands/Remove/RemoveCommand.cs b/cli/SimpleModule.Cli/Commands/Remove/RemoveCommand.cs new file mode 100644 index 00000000..5ddbf618 --- /dev/null +++ b/cli/SimpleModule.Cli/Commands/Remove/RemoveCommand.cs @@ -0,0 +1,84 @@ +using System.ComponentModel; +using SimpleModule.Cli.Infrastructure; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SimpleModule.Cli.Commands.Remove; + +public sealed class RemoveSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("NuGet package id of the installed module to remove.")] + public string PackageId { get; init; } = ""; +} + +/// +/// Removes a packaged module's reference from the host (csproj + CPM entry). +/// The module's database schema and data are deliberately left untouched — the +/// command warns loudly about what stays behind instead of dropping anything. +/// +public sealed class RemoveCommand : Command +{ + public override int Execute(CommandContext context, RemoveSettings settings) + { + var solution = SolutionContext.Discover(); + if (solution is null) + { + AnsiConsole.MarkupLine( + "[red]No .slnx file found. Run this command from inside a SimpleModule project.[/]" + ); + return 1; + } + + // Resolve the installed version + manifest BEFORE removing the reference + // so the schema warning can name what is left behind. + var installed = PackageReferenceManipulator + .GetPackageReferences(solution.ApiCsprojPath, solution.RootPath) + .FirstOrDefault(r => + string.Equals(r.Id, settings.PackageId, StringComparison.OrdinalIgnoreCase) + ); + var manifest = GlobalPackagesCache.TryReadManifest(settings.PackageId, installed.Version); + + var removed = PackageReferenceManipulator.RemovePackage( + solution.ApiCsprojPath, + solution.RootPath, + settings.PackageId + ); + if (!removed) + { + AnsiConsole.MarkupLine( + $"[red]{Markup.Escape(settings.PackageId)} is not referenced by {Markup.Escape(Path.GetFileName(solution.ApiCsprojPath))}.[/]" + ); + return 1; + } + + AnsiConsole.MarkupLine( + $"[green]✓ removed {Markup.Escape(settings.PackageId)} from {Markup.Escape(Path.GetFileName(solution.ApiCsprojPath))}[/]" + ); + + var schemaName = manifest?.Schema ?? "(unknown — manifest unavailable)"; + var warning = new List + { + $"Database schema [bold]{Markup.Escape(schemaName)}[/] and all of its tables/data were [bold]left in place[/].", + "Removing a module never drops data. To clean up manually:", + $" · drop the module's tables (schema/prefix: {Markup.Escape(schemaName)})", + " · remove its rows from the __EFMigrationsHistory table (if it shipped migrations)", + }; + if (manifest is not null && manifest.Permissions.Count > 0) + { + warning.Add( + $" · {manifest.Permissions.Count} permission(s) granted to roles remain until re-saved" + ); + } + + AnsiConsole.Write( + new Panel(string.Join("\n", warning)) + .Header("[yellow bold]Left behind[/]") + .Border(BoxBorder.Rounded) + .BorderColor(Color.Yellow) + ); + AnsiConsole.MarkupLine("[dim]Run 'dotnet build' to verify the host still compiles.[/]"); + + return 0; + } +} diff --git a/cli/SimpleModule.Cli/Infrastructure/GlobalPackagesCache.cs b/cli/SimpleModule.Cli/Infrastructure/GlobalPackagesCache.cs new file mode 100644 index 00000000..e3db5216 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/GlobalPackagesCache.cs @@ -0,0 +1,76 @@ +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Reads module manifests out of the NuGet global packages cache +/// (~/.nuget/packages or NUGET_PACKAGES). +/// +public static class GlobalPackagesCache +{ + public static string RootPath => + Environment.GetEnvironmentVariable("NUGET_PACKAGES") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".nuget", + "packages" + ); + + /// + /// Returns the manifest for an installed package, or null when the package + /// (or a manifest inside it) cannot be found. Without a version the highest + /// cached one is used. + /// + public static ModuleManifestData? TryReadManifest(string packageId, string? version) + { + var packageDir = Path.Combine(RootPath, packageId.ToLowerInvariant()); + if (!Directory.Exists(packageDir)) + { + return null; + } + + var versionDirs = version is not null + ? new[] { Path.Combine(packageDir, version.ToLowerInvariant()) } + : Directory + .EnumerateDirectories(packageDir) + .OrderByDescending(Path.GetFileName, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + foreach (var versionDir in versionDirs) + { + if (!Directory.Exists(versionDir)) + { + continue; + } + + // sm pack puts module-manifest.json at the package root. + var manifestPath = Path.Combine(versionDir, "module-manifest.json"); + if (File.Exists(manifestPath)) + { + var parsed = ModuleManifestData.TryParse(File.ReadAllText(manifestPath)); + if (parsed is not null) + { + return parsed; + } + } + + var libDir = Path.Combine(versionDir, "lib"); + if (!Directory.Exists(libDir)) + { + continue; + } + + var dll = Directory + .EnumerateFiles(libDir, packageId + ".dll", SearchOption.AllDirectories) + .FirstOrDefault(); + if (dll is not null) + { + var manifest = AssemblyManifestReader.TryRead(dll); + if (manifest is not null) + { + return manifest; + } + } + } + + return null; + } +} diff --git a/cli/SimpleModule.Cli/Program.cs b/cli/SimpleModule.Cli/Program.cs index 19b4d017..0364b42a 100644 --- a/cli/SimpleModule.Cli/Program.cs +++ b/cli/SimpleModule.Cli/Program.cs @@ -1,3 +1,4 @@ +using SimpleModule.Cli.Commands.Add; using SimpleModule.Cli.Commands.Dev; using SimpleModule.Cli.Commands.Doctor; using SimpleModule.Cli.Commands.Install; @@ -6,6 +7,7 @@ using SimpleModule.Cli.Commands.Maintenance; using SimpleModule.Cli.Commands.New; using SimpleModule.Cli.Commands.Pack; +using SimpleModule.Cli.Commands.Remove; using SimpleModule.Cli.Commands.Skill; using SimpleModule.Cli.Commands.Version; using Spectre.Console.Cli; @@ -60,6 +62,21 @@ .AddCommand("install") .WithDescription("Install a SimpleModule package from NuGet"); + config + .AddCommand("add") + .WithDescription( + "Install a packaged SimpleModule module: compat check, CPM-aware reference, build, migrations, doctor" + ) + .WithExample("add", "SimpleModule.Products") + .WithExample("add", "SimpleModule.Products", "--version", "1.2.0", "--source", "./feed"); + + config + .AddCommand("remove") + .WithDescription( + "Remove an installed module's reference (database schema and data are left in place)" + ) + .WithExample("remove", "SimpleModule.Products"); + config .AddCommand("pack") .WithDescription( From db87767519fbb096bdbe838c71ffa9d71c9ca527 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 12:05:56 +0200 Subject: [PATCH 19/31] fix(cli): bare frameworkCompat lower bounds include prereleases of that version --- .../Infrastructure/FrameworkCompatChecker.cs | 10 ++++++++++ .../FrameworkCompatCheckerTests.cs | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/cli/SimpleModule.Cli/Infrastructure/FrameworkCompatChecker.cs b/cli/SimpleModule.Cli/Infrastructure/FrameworkCompatChecker.cs index fc72b022..97fed5c1 100644 --- a/cli/SimpleModule.Cli/Infrastructure/FrameworkCompatChecker.cs +++ b/cli/SimpleModule.Cli/Infrastructure/FrameworkCompatChecker.cs @@ -40,6 +40,16 @@ public static CompatResult Check(string range, string hostVersion) return Unparseable(range); } + // The generator derives the lower bound from the referenced + // assembly's numeric version, which cannot carry a prerelease tag. + // A bare ">=X.Y.Z" therefore means ">=X.Y.Z-0": prereleases of + // X.Y.Z (e.g. a host on 0.0.99-local with a module built against + // that same prerelease) satisfy the bound. + if (parsed.Prerelease.Length == 0) + { + parsed = parsed with { Prerelease = "0" }; + } + lower = parsed; } else if (part.StartsWith('<')) diff --git a/tests/SimpleModule.Cli.Tests/FrameworkCompatCheckerTests.cs b/tests/SimpleModule.Cli.Tests/FrameworkCompatCheckerTests.cs index 58293865..6ccb18c7 100644 --- a/tests/SimpleModule.Cli.Tests/FrameworkCompatCheckerTests.cs +++ b/tests/SimpleModule.Cli.Tests/FrameworkCompatCheckerTests.cs @@ -23,7 +23,11 @@ public void IsCompatible_EvaluatesRanges(string range, string version, bool expe [Theory] [InlineData(">=0.0.39-local <1.0.0", "0.0.39-local", true)] - [InlineData(">=0.0.39 <1.0.0", "0.0.39-local", false)] // prerelease < release + // A bare lower bound means ">=X.Y.Z-0": the generator derives it from the + // numeric assembly version, so prereleases of that exact version satisfy it. + [InlineData(">=0.0.39 <1.0.0", "0.0.39-local", true)] + [InlineData(">=0.0.39 <1.0.0", "0.0.38", false)] // older release still refused + [InlineData(">=0.0.39 <1.0.0", "0.0.38-zz", false)] // prerelease of older version refused [InlineData(">=0.0.39-alpha <1.0.0", "0.0.39", true)] // release > prerelease public void IsCompatible_HandlesPrereleaseOrdering(string range, string version, bool expected) { From c59129d7258e38717de5f9693c6f8398fce2a799 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 12:08:25 +0200 Subject: [PATCH 20/31] fix(generator): emit partial HostDbContext in non-identity hosts (CS0260 with Vogen conventions) --- .../Emitters/HostDbContextEmitter.cs | 6 +++++- .../HostDbContextGenerationTests.cs | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs b/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs index d96b94d7..43974cc3 100644 --- a/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/HostDbContextEmitter.cs @@ -144,7 +144,11 @@ string ModuleName } else { - sb.AppendLine("public class HostDbContext("); + // Always partial: ValueConverterConventionsEmitter contributes a + // partial ConfigureConventions whenever Vogen value objects exist, + // independent of whether an Identity context is present (CS0260 + // in non-identity hosts otherwise). + sb.AppendLine("public partial class HostDbContext("); sb.AppendLine(" DbContextOptions options,"); sb.AppendLine(" IOptions dbOptions"); sb.AppendLine(") : DbContext(options)"); diff --git a/tests/SimpleModule.Generator.Tests/HostDbContextGenerationTests.cs b/tests/SimpleModule.Generator.Tests/HostDbContextGenerationTests.cs index 5b59673f..17702c95 100644 --- a/tests/SimpleModule.Generator.Tests/HostDbContextGenerationTests.cs +++ b/tests/SimpleModule.Generator.Tests/HostDbContextGenerationTests.cs @@ -193,6 +193,19 @@ public void PlainDbContext_HostExtendsDbContext() source.Should().NotContain("IdentityDbContext"); } + [Fact] + public void PlainDbContext_HostIsPartial_ForVogenConventionsCounterpart() + { + // ValueConverterConventionsEmitter contributes a partial HostDbContext + // whenever Vogen value objects exist — the main declaration must be + // partial in BOTH the identity and plain branches or non-identity + // hosts fail with CS0260. + var compilation = GeneratorTestHelper.CreateCompilationWithEfCore(ModuleWithDbContext); + var result = GeneratorTestHelper.RunGenerator(compilation); + + GetHostDbContext(result).Should().Contain("public partial class HostDbContext("); + } + [Fact] public void IdentityDbContext_HostExtendsIdentityDbContext() { From 41e8767b02b3c42b4cb7b009b774c091c78d73ca Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 12:12:25 +0200 Subject: [PATCH 21/31] fix(scripts): do not create source module dirs for package-installed modules during TS extraction --- scripts/extract-ts-types.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/extract-ts-types.mjs b/scripts/extract-ts-types.mjs index 644f0a32..f0807a82 100644 --- a/scripts/extract-ts-types.mjs +++ b/scripts/extract-ts-types.mjs @@ -55,6 +55,12 @@ for (const file of files) { const tsContent = tsMatch[1]; const projectDir = `SimpleModule.${moduleName}`; + + // Package-installed modules have no source directory here — creating one + // would make the module look like a source module to tooling (sm list, + // doctor). Their TS types ship prebuilt inside the package instead. + if (!existsSync(resolve(modulesDir, moduleName))) continue; + const outPath = resolve( modulesDir, moduleName, From a64f0b423fd98f9d4df9220bd41e6d1d5b716d57 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 12:19:40 +0200 Subject: [PATCH 22/31] feat(cli): scaffold module-kind manifest emission for downstream modules --- .../Commands/New/NewProjectCommand.cs | 5 +++ .../Templates/ProjectTemplates.cs | 36 +++++++++++++++++++ .../NewProjectScaffoldTests.ModulesProps.cs | 21 +++++++++++ 3 files changed, 62 insertions(+) create mode 100644 tests/SimpleModule.Cli.Tests/NewProjectScaffoldTests.ModulesProps.cs diff --git a/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs index 9fd7ed70..39a9dad8 100644 --- a/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewProjectCommand.cs @@ -122,6 +122,10 @@ public static void ScaffoldProject( Path.Combine(rootDir, "Directory.Packages.props"), projectTemplates.DirectoryPackagesProps() ); + File.WriteAllText( + Path.Combine(modulesDir, "Directory.Build.props"), + projectTemplates.ModulesDirectoryBuildProps() + ); File.WriteAllText(Path.Combine(rootDir, "global.json"), projectTemplates.GlobalJson()); File.WriteAllText( Path.Combine(rootDir, "package.json"), @@ -310,6 +314,7 @@ string rootDir Plan(Path.Combine(rootDir, $"{projectName}.slnx")); Plan(Path.Combine(rootDir, "Directory.Build.props")); Plan(Path.Combine(rootDir, "Directory.Packages.props")); + Plan(Path.Combine(modulesDir, "Directory.Build.props")); Plan(Path.Combine(rootDir, "global.json")); Plan(Path.Combine(rootDir, "package.json")); Plan(Path.Combine(rootDir, "biome.json")); diff --git a/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs b/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs index ae98a4fc..3436c5ac 100644 --- a/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/ProjectTemplates.cs @@ -53,6 +53,42 @@ public string Slnx(string projectName) return content.Replace(BaseProjectName, projectName, StringComparison.Ordinal); } + /// + /// Directory.Build.props for src/modules: runs the source generator in + /// module-kind on every module project so each module assembly carries its + /// compile-time manifest ([assembly: ModuleManifest]) — required by + /// sm pack and host-side bundle discovery. Standalone scaffolds get + /// the generator from NuGet (CPM-pinned); repo-clone scaffolds reference the + /// repo's generator project as an analyzer like in-repo modules do. + /// + public string ModulesDirectoryBuildProps() + { + var generatorReference = _solution is null + ? """""" + : $""""""; + + return $""" + + + + Module + + + + + + + + {generatorReference} + + + + """; + } + public string DirectoryBuildProps() { if (_solution is null) diff --git a/tests/SimpleModule.Cli.Tests/NewProjectScaffoldTests.ModulesProps.cs b/tests/SimpleModule.Cli.Tests/NewProjectScaffoldTests.ModulesProps.cs new file mode 100644 index 00000000..6757850f --- /dev/null +++ b/tests/SimpleModule.Cli.Tests/NewProjectScaffoldTests.ModulesProps.cs @@ -0,0 +1,21 @@ +using FluentAssertions; + +namespace SimpleModule.Cli.Tests; + +public sealed partial class NewProjectScaffoldTests +{ + [Fact] + public void Scaffold_WritesModulesDirectoryBuildProps_WithModuleKind() + { + var (_, rootDir) = ScaffoldStandalone(); + + var propsPath = Path.Combine(rootDir, "src", "modules", "Directory.Build.props"); + File.Exists(propsPath).Should().BeTrue(); + + var content = File.ReadAllText(propsPath); + content.Should().Contain(" Date: Wed, 10 Jun 2026 12:28:07 +0200 Subject: [PATCH 23/31] docs(cli): packaging command reference; session 2 task log --- docs/site/.vitepress/config.ts | 1 + docs/site/advanced/module-packaging.md | 18 ++++-- docs/site/cli/packaging.md | 81 ++++++++++++++++++++++++++ tasks/todo.md | 24 ++++++++ 4 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 docs/site/cli/packaging.md diff --git a/docs/site/.vitepress/config.ts b/docs/site/.vitepress/config.ts index 9330960c..2134d984 100644 --- a/docs/site/.vitepress/config.ts +++ b/docs/site/.vitepress/config.ts @@ -94,6 +94,7 @@ export default defineConfig({ { text: 'sm new module', link: '/cli/new-module' }, { text: 'sm new feature', link: '/cli/new-feature' }, { text: 'sm doctor', link: '/cli/doctor' }, + { text: 'sm pack / add / remove', link: '/cli/packaging' }, ], }, ], diff --git a/docs/site/advanced/module-packaging.md b/docs/site/advanced/module-packaging.md index 25ecc08d..0f47a482 100644 --- a/docs/site/advanced/module-packaging.md +++ b/docs/site/advanced/module-packaging.md @@ -23,7 +23,7 @@ attach embedded resources. The manifest therefore travels as an assembly-level attribute, which is the closest in-assembly equivalent: readable at runtime via reflection (`ModuleManifestReader.TryRead(assembly)`) and by tooling via `System.Reflection.Metadata` without loading the assembly or its dependencies. -`sm pack` (planned) additionally extracts the manifest to a +`sm pack` additionally extracts the manifest to a `module-manifest.json` inside the nupkg so feeds and registries can read it without touching the assembly at all. ::: @@ -145,16 +145,22 @@ should use migrations from the start. Module bundles are Vite **library-mode** builds and MUST externalize: -- `react` -- `react-dom` +- `react` (and `react/jsx-runtime`) +- `react-dom` (and `react-dom/client`) - `@inertiajs/react` -- `SimpleModule.UI` (the shared component library) These are provided by the host at runtime via the import map in the HTML shell. A module that bundles its own React copy breaks hooks (two React instances) and -bloats every page load. `sm pack` (planned) validates the built bundle and +bloats every page load. `sm pack` validates the built bundle and fails closed when one of the externals is found inlined. +::: warning @simplemodule/ui is currently inlined +The shared UI component library is not yet host-provided (no vendor bundle / +import-map entry), so each module bundle statically includes the components it +uses. Externalizing it is planned; when that lands the externals contract and +the pack-time validation will extend to `@simplemodule/ui`. +::: + ### How the host finds module bundles The host builds a module → bundle map from the loaded manifests @@ -186,7 +192,7 @@ legacy convention probe: `/_content/SimpleModule.{Module}/…` then ``` -- Installation tooling (`sm add`, planned) checks the host's framework version +- Installation tooling (`sm add`) checks the host's framework version against this range **before** installing and refuses incompatible modules (override with `--force` at your own risk). diff --git a/docs/site/cli/packaging.md b/docs/site/cli/packaging.md new file mode 100644 index 00000000..5130694d --- /dev/null +++ b/docs/site/cli/packaging.md @@ -0,0 +1,81 @@ +# Module packaging commands + +`sm` distributes modules as standard NuGet packages (see +[Module Packaging](/advanced/module-packaging) for the package contract). +Four commands cover the local lifecycle: `pack`, `add`, `remove`, and the +packaged-modules view in `list`. + +The package registry defaults to nuget.org. Point a solution at a different +NuGet V3 feed by adding `sm.json` to the solution root: + +```json +{ "registry": "https://my-feed.example.com/v3/index.json" } +``` + +## sm pack + +```bash +sm pack [module-path] [--version ] [--output ] [--skip-tests] [-c ] +``` + +Builds, validates and packs a module (and its `.Contracts` project) into +nupkgs. The pipeline fails closed at the first violated step: + +1. **Frontend build** — a fresh production Vite build (when the module has a + `package.json`). +2. **Externals validation** — the built bundle must not inline react, + react-dom, react/jsx-runtime or @inertiajs/react (host-provided). A module + that bundles React breaks hooks at runtime. +3. **`dotnet build`** (Release by default). +4. **`dotnet test`** of the module's test project (skip with `--skip-tests`). +5. **Manifest validation** — the built assembly must carry a parseable + schema-v1 manifest whose id matches the assembly and whose declared + frontend entry exists on disk. +6. **`dotnet pack`** — also writes `module-manifest.json` into the nupkg root + and guarantees the `simplemodule-module` package tag, without editing your + project files. + +::: tip Prerelease frameworks +Packing a *stable* module version against a *prerelease* framework fails with +NU5104 — pass a prerelease `--version` (e.g. `1.2.0-rc.1`) in that case. +::: + +## sm add + +```bash +sm add [--version ] [--source ] [--skip-migrations] [--skip-doctor] +``` + +Installs a packaged module into the host application: + +1. Resolves the nupkg from `--source` (local folder feed or NuGet V3 service + index URL), or the `sm.json` registry. +2. Reads the module manifest — packages without one are refused (use + `sm install` for plain NuGet packages). +3. **Compatibility gate**: checks the manifest's `frameworkCompat` range + against the host's `SimpleModule.Core` version *before touching any file*. +4. Registers local folder feeds in `nuget.config`. +5. Adds the package reference — **CPM-aware**: with Central Package + Management the version goes into `Directory.Packages.props` and the csproj + gets a version-less `PackageReference`. +6. `dotnet build`, then applies the module's migrations by running the host + once with `SIMPLEMODULE_MIGRATE_ONLY=1` (database initialization runs and + the process exits without serving traffic). +7. Runs `sm doctor`. + +## sm remove + +```bash +sm remove +``` + +Removes the package reference (csproj + CPM entry). The module's database +schema and data are **never dropped** — the command prints exactly what was +left behind (schema name, migration history rows, permission grants) so the +cleanup is a deliberate, manual decision. + +## sm list + +`sm list` shows source modules (with route prefixes and endpoint counts) and a +second table of installed packaged modules with their versions and framework +compatibility status against the current host. diff --git a/tasks/todo.md b/tasks/todo.md index 5d9a3ed3..f86a5365 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -101,3 +101,27 @@ Branch: worktree-module-packaging-s1 - All suites green: solution build 0 warnings; Core 258, Generator 213, Database 93, DevTools 35, CLI 136 (xunit v3 exe), 15 module suites all pass; `npm run check` + `validate-pages` clean. - Deviation: manifest is an assembly-level attribute, not an embedded resource — Roslyn generators cannot emit resources. `sm pack` (Session 2) will additionally extract `module-manifest.json` into the nupkg. - Next (Session 2): `sm pack` / `sm add` / `sm remove` / `sm list`; handle CPM (NU1008) on add; force production frontend build on pack. + +# Task: Module distribution — Session 2 (pack & add) + +Full plan: docs/superpowers/plans/2026-06-10-module-packaging-session2.md + +## Plan + +- [x] SmConfig (sm.json registry abstraction) + FrameworkCompatChecker (SemVer ranges, prerelease-aware lower bounds) +- [x] AssemblyManifestReader (System.Reflection.Metadata, no assembly load) + NupkgManifestReader + ModuleManifestData +- [x] PackageReferenceManipulator (CPM-aware, #259) + NuGetConfigManipulator + HostFrameworkVersionResolver +- [x] NuGetClient (V3 service index + flat container + local feeds) + BundleExternalsValidator +- [x] SIMPLEMODULE_MIGRATE_ONLY framework hook (deterministic CLI migrations, #258) +- [x] sm pack (production vite build #260, externals fail-closed, tests, manifest validation, module-manifest.json in nupkg root) +- [x] sm add (compat gate BEFORE changes, CPM-aware, nuget.config, build, migrate-only run, auto-doctor) +- [x] sm remove (reference + CPM entry removed; loud schema/data left-behind warning) +- [x] sm list packaged-modules table with framework compat status +- [x] Scaffold src/modules/Directory.Build.props so downstream modules emit manifests (sm pack works for third parties) +- [x] Checkpoint: packed FeatureFlags from worktree → sm new project Demo (0.0.99-local feed) → sm add (compat ✓, migrations ✓, doctor ✓) → module page renders in browser → sm pack Items (third-party proof) → sm remove with warning + +## Review + +- Bugs found & fixed by the checkpoint: generator emitted non-partial HostDbContext in non-identity hosts (CS0260 with Vogen conventions); TS type extraction created fake source-module dirs for installed packages; compat checker refused prereleases of the derived lower bound. +- Issues filed: #261 (@simplemodule/ui not host-provided), #262 (bare scaffold 500s on RequirePermission without an auth module). +- All suites green; full build 0 warnings. From 26da25d1701effc7a8e5d4b3f609ede46d1bc93b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 10 Jun 2026 12:40:37 +0200 Subject: [PATCH 24/31] fix: address session 2 code-review findings - host DbContext initializes before module contexts (fresh-DB EnsureCreated ordering) - manifest registry tolerates unreadable manifests (newer schemaVersion no longer 500s every page) - emit '>=X.Y.Z-0' lower bounds so prerelease semantics live in the manifest - graceful host disposal before migrate-only exit - sm add prefers highest stable version (explicit SemVer sort, shared comparer) - global packages cache picks versions by SemVer order, not ordinal sort - nuget.config source keys hash the full feed path (no duplicate keys) - sm pack --skip-externals-check escape hatch for react-is-style false positives - npx/npm routed through cmd.exe on Windows - resolve-page: single ordered candidate list, URL-based cache, complete error message --- .../Commands/Add/AddCommand.cs | 11 ++- .../Commands/Pack/PackCommand.cs | 25 +++-- .../Infrastructure/GlobalPackagesCache.cs | 2 +- .../Infrastructure/NuGetClient.cs | 50 +--------- .../Infrastructure/NuGetConfigManipulator.cs | 15 ++- .../Infrastructure/ProcessRunner.cs | 12 ++- .../Infrastructure/SemVerStringComparer.cs | 57 +++++++++++ .../Emitters/ModuleManifestEmitter.cs | 8 +- .../Modules/ModuleManifestRegistry.cs | 14 ++- .../SimpleModuleHostExtensions.cs | 10 +- .../SimpleModule.Client/src/resolve-page.ts | 96 ++++++++++--------- 11 files changed, 189 insertions(+), 111 deletions(-) create mode 100644 cli/SimpleModule.Cli/Infrastructure/SemVerStringComparer.cs diff --git a/cli/SimpleModule.Cli/Commands/Add/AddCommand.cs b/cli/SimpleModule.Cli/Commands/Add/AddCommand.cs index 242ef619..10c1125d 100644 --- a/cli/SimpleModule.Cli/Commands/Add/AddCommand.cs +++ b/cli/SimpleModule.Cli/Commands/Add/AddCommand.cs @@ -82,7 +82,14 @@ public override async Task ExecuteAsync(CommandContext context, AddSettings return Fail($"Package {settings.PackageId} not found on '{source}'."); } - resolvedVersion = settings.Version ?? versions[^1]; + // "Latest" prefers the highest stable version; prereleases only when + // nothing stable exists. The flat-container array's order is not + // guaranteed by every feed, so sort explicitly. + var sorted = versions.OrderBy(v => v, SemVerStringComparer.Instance).ToList(); + resolvedVersion = + settings.Version + ?? sorted.LastOrDefault(v => !SemVerStringComparer.IsPrerelease(v)) + ?? sorted[^1]; if (!versions.Contains(resolvedVersion)) { return Fail( @@ -183,7 +190,7 @@ await NuGetClient.DownloadNupkgAsync( { return Fail( "Module migration run failed (the package reference is in place; fix the " - + "database issue and re-run with SIMPLEMODULE_MIGRATE_ONLY=1):\n" + + "database issue and re-run 'SIMPLEMODULE_MIGRATE_ONLY=1 dotnet run --project '):\n" + Tail(migrate.Error, migrate.Output) ); } diff --git a/cli/SimpleModule.Cli/Commands/Pack/PackCommand.cs b/cli/SimpleModule.Cli/Commands/Pack/PackCommand.cs index 8c2d3f0a..a59855d2 100644 --- a/cli/SimpleModule.Cli/Commands/Pack/PackCommand.cs +++ b/cli/SimpleModule.Cli/Commands/Pack/PackCommand.cs @@ -23,6 +23,12 @@ public sealed class PackSettings : CommandSettings [Description("Skip running the module's test project.")] public bool SkipTests { get; init; } + [CommandOption("--skip-externals-check")] + [Description( + "Treat inlined-React markers in the bundle as warnings instead of errors (escape hatch for false positives from React-ecosystem libraries like react-is)." + )] + public bool SkipExternalsCheck { get; init; } + [CommandOption("-c|--configuration")] [Description("Build configuration. Default: Release")] public string Configuration { get; init; } = "Release"; @@ -86,18 +92,25 @@ public override async Task ExecuteAsync(CommandContext context, PackSetting var violations = BundleExternalsValidator.Validate(Path.Combine(implDir, "wwwroot")); if (violations.Count > 0) { + var color = settings.SkipExternalsCheck ? "yellow" : "red"; + var symbol = settings.SkipExternalsCheck ? "!" : "✗"; foreach (var violation in violations) { AnsiConsole.MarkupLine( - $"[red]✗ {Markup.Escape(Path.GetFileName(violation.File))} inlines a host-provided library (marker: {Markup.Escape(violation.Marker)})[/]" + $"[{color}]{symbol} {Markup.Escape(Path.GetFileName(violation.File))} contains an inlined-React marker ({Markup.Escape(violation.Marker)})[/]" ); } - return Fail( - "Module bundles must externalize react, react-dom, react/jsx-runtime and " - + "@inertiajs/react — they are provided by the host. Check the module's " - + "vite.config.ts uses defineModuleConfig from @simplemodule/client." - ); + if (!settings.SkipExternalsCheck) + { + return Fail( + "Module bundles must externalize react, react-dom, react/jsx-runtime and " + + "@inertiajs/react — they are provided by the host. Check the module's " + + "vite.config.ts uses defineModuleConfig from @simplemodule/client. " + + "If the marker comes from a legitimately bundled library (e.g. react-is), " + + "re-run with --skip-externals-check." + ); + } } } diff --git a/cli/SimpleModule.Cli/Infrastructure/GlobalPackagesCache.cs b/cli/SimpleModule.Cli/Infrastructure/GlobalPackagesCache.cs index e3db5216..d3b745f1 100644 --- a/cli/SimpleModule.Cli/Infrastructure/GlobalPackagesCache.cs +++ b/cli/SimpleModule.Cli/Infrastructure/GlobalPackagesCache.cs @@ -31,7 +31,7 @@ public static class GlobalPackagesCache ? new[] { Path.Combine(packageDir, version.ToLowerInvariant()) } : Directory .EnumerateDirectories(packageDir) - .OrderByDescending(Path.GetFileName, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(d => Path.GetFileName(d), SemVerStringComparer.Instance) .ToArray(); foreach (var versionDir in versionDirs) diff --git a/cli/SimpleModule.Cli/Infrastructure/NuGetClient.cs b/cli/SimpleModule.Cli/Infrastructure/NuGetClient.cs index 15adeaa4..0062bf45 100644 --- a/cli/SimpleModule.Cli/Infrastructure/NuGetClient.cs +++ b/cli/SimpleModule.Cli/Infrastructure/NuGetClient.cs @@ -51,7 +51,7 @@ public static bool IsLocalDirectorySource(string source) => } return candidates - .OrderByDescending(c => c.Version, VersionStringComparer.Instance) + .OrderByDescending(c => c.Version, SemVerStringComparer.Instance) .Select(c => c.Path) .FirstOrDefault(); } @@ -118,54 +118,6 @@ private static async Task ResolveFlatContainerAsync(Uri serviceIndexUrl) ); } - /// Orders dotted version strings numerically with release > prerelease. - private sealed class VersionStringComparer : IComparer - { - public static readonly VersionStringComparer Instance = new(); - - public int Compare(string? x, string? y) - { - var (xCore, xPre) = Split(x ?? ""); - var (yCore, yPre) = Split(y ?? ""); - - var xParts = xCore.Split('.'); - var yParts = yCore.Split('.'); - for (var i = 0; i < Math.Max(xParts.Length, yParts.Length); i++) - { - var xNum = i < xParts.Length && int.TryParse(xParts[i], out var xv) ? xv : 0; - var yNum = i < yParts.Length && int.TryParse(yParts[i], out var yv) ? yv : 0; - var byNum = xNum.CompareTo(yNum); - if (byNum != 0) - { - return byNum; - } - } - - if (xPre.Length == 0 && yPre.Length == 0) - { - return 0; - } - - if (xPre.Length == 0) - { - return 1; - } - - if (yPre.Length == 0) - { - return -1; - } - - return string.CompareOrdinal(xPre, yPre); - } - - private static (string Core, string Prerelease) Split(string version) - { - var dash = version.IndexOf('-', StringComparison.Ordinal); - return dash < 0 ? (version, "") : (version[..dash], version[(dash + 1)..]); - } - } - [GeneratedRegex("^[0-9]")] private static partial Regex VersionStartRegex(); } diff --git a/cli/SimpleModule.Cli/Infrastructure/NuGetConfigManipulator.cs b/cli/SimpleModule.Cli/Infrastructure/NuGetConfigManipulator.cs index f5b0c9ff..557373c5 100644 --- a/cli/SimpleModule.Cli/Infrastructure/NuGetConfigManipulator.cs +++ b/cli/SimpleModule.Cli/Infrastructure/NuGetConfigManipulator.cs @@ -1,3 +1,6 @@ +using System.Security.Cryptography; +using System.Text; + namespace SimpleModule.Cli.Infrastructure; /// @@ -50,6 +53,14 @@ public static void EnsureLocalSource(string solutionRoot, string feedDirectory) File.WriteAllLines(configPath, lines); } - private static string SourceKey(string feedDirectory) => - "sm-local-" + Path.GetFileName(feedDirectory.TrimEnd(Path.DirectorySeparatorChar)); + private static string SourceKey(string feedDirectory) + { + // Key on the full path (hashed) — two different feeds sharing a leaf + // directory name must not produce duplicate keys, which NuGet + // rejects when parsing the config. + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(feedDirectory))); +#pragma warning disable CA1308 // lowercase is conventional for nuget.config keys + return "sm-local-" + hash[..8].ToLowerInvariant(); +#pragma warning restore CA1308 + } } diff --git a/cli/SimpleModule.Cli/Infrastructure/ProcessRunner.cs b/cli/SimpleModule.Cli/Infrastructure/ProcessRunner.cs index 5d835197..b7915101 100644 --- a/cli/SimpleModule.Cli/Infrastructure/ProcessRunner.cs +++ b/cli/SimpleModule.Cli/Infrastructure/ProcessRunner.cs @@ -17,6 +17,16 @@ public static async Task RunAsync( IReadOnlyDictionary? environment = null ) { + // npm/npx are .cmd shims on Windows; CreateProcess cannot start them + // directly (and .NET blocks cmd files with ArgumentList), so route + // through cmd.exe there. + var actualArguments = arguments; + if (OperatingSystem.IsWindows() && fileName is "npx" or "npm") + { + actualArguments = ["/c", fileName, .. arguments]; + fileName = "cmd.exe"; + } + var psi = new ProcessStartInfo { FileName = fileName, @@ -25,7 +35,7 @@ public static async Task RunAsync( UseShellExecute = false, WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory, }; - foreach (var argument in arguments) + foreach (var argument in actualArguments) { psi.ArgumentList.Add(argument); } diff --git a/cli/SimpleModule.Cli/Infrastructure/SemVerStringComparer.cs b/cli/SimpleModule.Cli/Infrastructure/SemVerStringComparer.cs new file mode 100644 index 00000000..27d3dac9 --- /dev/null +++ b/cli/SimpleModule.Cli/Infrastructure/SemVerStringComparer.cs @@ -0,0 +1,57 @@ +namespace SimpleModule.Cli.Infrastructure; + +/// +/// Orders dotted version strings numerically (1.10.0 > 1.9.0) with releases +/// above their prereleases (1.0.0 > 1.0.0-rc). The single source of version +/// ordering for the CLI — local feed selection, registry version picking and +/// global-cache lookups all share it. +/// +public sealed class SemVerStringComparer : IComparer +{ + public static readonly SemVerStringComparer Instance = new(); + + public int Compare(string? x, string? y) + { + var (xCore, xPre) = Split(x ?? ""); + var (yCore, yPre) = Split(y ?? ""); + + var xParts = xCore.Split('.'); + var yParts = yCore.Split('.'); + for (var i = 0; i < Math.Max(xParts.Length, yParts.Length); i++) + { + var xNum = i < xParts.Length && int.TryParse(xParts[i], out var xv) ? xv : 0; + var yNum = i < yParts.Length && int.TryParse(yParts[i], out var yv) ? yv : 0; + var byNum = xNum.CompareTo(yNum); + if (byNum != 0) + { + return byNum; + } + } + + if (xPre.Length == 0 && yPre.Length == 0) + { + return 0; + } + + if (xPre.Length == 0) + { + return 1; + } + + if (yPre.Length == 0) + { + return -1; + } + + return string.CompareOrdinal(xPre, yPre); + } + + public static bool IsPrerelease(string version) => + version.Contains('-', StringComparison.Ordinal); + + private static (string Core, string Prerelease) Split(string version) + { + var dash = version.IndexOf('-', StringComparison.Ordinal); + return dash < 0 ? (version, "") : (version[..dash], version[(dash + 1)..]); + } +} diff --git a/framework/SimpleModule.Generator/Emitters/ModuleManifestEmitter.cs b/framework/SimpleModule.Generator/Emitters/ModuleManifestEmitter.cs index 4b5d90e7..34ef6364 100644 --- a/framework/SimpleModule.Generator/Emitters/ModuleManifestEmitter.cs +++ b/framework/SimpleModule.Generator/Emitters/ModuleManifestEmitter.cs @@ -128,10 +128,14 @@ private static string DefaultFrameworkCompat(string coreVersion) if (string.IsNullOrEmpty(coreVersion)) return ""; + // The assembly identity carries no prerelease tag, so the referenced Core + // may actually be a prerelease of this version. "-0" is SemVer's smallest + // prerelease: the bound admits prereleases of coreVersion itself, encoding + // that semantic in the manifest rather than in each consumer. var majorPart = coreVersion.Split('.')[0]; return int.TryParse(majorPart, out var major) - ? $">={coreVersion} <{major + 1}.0.0" - : $">={coreVersion}"; + ? $">={coreVersion}-0 <{major + 1}.0.0" + : $">={coreVersion}-0"; } private static string StripGlobal(string fullyQualifiedName) => diff --git a/framework/SimpleModule.Hosting/Modules/ModuleManifestRegistry.cs b/framework/SimpleModule.Hosting/Modules/ModuleManifestRegistry.cs index ae6b2fe2..e2a41f53 100644 --- a/framework/SimpleModule.Hosting/Modules/ModuleManifestRegistry.cs +++ b/framework/SimpleModule.Hosting/Modules/ModuleManifestRegistry.cs @@ -23,7 +23,19 @@ public ModuleManifestRegistry(IEnumerable modules) if (!seenAssemblies.Add(assembly.FullName ?? assembly.GetName().Name ?? "")) continue; - var manifest = ModuleManifestReader.TryRead(assembly); + ModuleManifest? manifest; + try + { + manifest = ModuleManifestReader.TryRead(assembly); + } + catch (ModuleManifestException) + { + // One unreadable manifest (newer schemaVersion, corrupt JSON) must + // not take down every page render — the module simply behaves like + // a pre-manifest module and falls back to convention resolution. + continue; + } + if (manifest is not null && !_byName.ContainsKey(manifest.Name)) _byName[manifest.Name] = manifest; } diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 119569a5..791d8815 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -243,7 +243,12 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) ) { using var scope = app.Services.CreateScope(); - var infos = scope.ServiceProvider.GetServices(); + // Host context FIRST: if a packaged module's MigrateAsync ran before the + // host's EnsureCreatedAsync on a fresh database, EnsureCreated would see a + // non-empty database and silently skip creating the host tables. + var infos = scope + .ServiceProvider.GetServices() + .OrderBy(i => i.ModuleName == DatabaseConstants.HostModuleName ? 0 : 1); foreach (var info in infos) { @@ -280,6 +285,9 @@ public static async Task UseSimpleModuleInfrastructure(this WebApplication app) app.Logger.LogInformation( "SIMPLEMODULE_MIGRATE_ONLY=1: database initialization complete; exiting without starting the server." ); + // Graceful teardown (Wolverine, DbContext pools, SQLite WAL) before the + // hard exit — Environment.Exit alone would skip all disposal. + await app.DisposeAsync(); Environment.Exit(0); } diff --git a/packages/SimpleModule.Client/src/resolve-page.ts b/packages/SimpleModule.Client/src/resolve-page.ts index 3ea776f6..564aaffb 100644 --- a/packages/SimpleModule.Client/src/resolve-page.ts +++ b/packages/SimpleModule.Client/src/resolve-page.ts @@ -1,8 +1,15 @@ -// Caches the assembly name that successfully served a module's bundle, keyed by -// the module's short name. Inertia calls resolvePage on every navigation, so -// without this the "wrong" candidate would 404 again on every page load for that -// module. We only ever store a name that actually resolved. -const resolvedAssemblies = new Map(); +interface BundleCandidate { + /** Base URL of the bundle (no cache-buster suffix). */ + url: string; + /** Assembly name, used in error messages only. */ + assemblyName: string; +} + +// Caches the candidate that successfully served a module's bundle, keyed by the +// module's short name. Inertia calls resolvePage on every navigation, so without +// this the "wrong" candidate would 404 again on every page load for that module. +// We only ever store a candidate whose URL actually resolved. +const resolvedBundles = new Map(); // Module → bundle path map injected into the HTML shell by the host from each // module's compile-time manifest (