From 3e80c0dc104fc1680eff865a8618798db5d9631a Mon Sep 17 00:00:00 2001 From: Dylan de Beer Date: Fri, 31 Jul 2026 08:36:35 +0200 Subject: [PATCH 1/2] fix(templates): scaffold correctly for any solution name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine named directories with the raw name and wrote a sanitised form into file contents, including the .slnx project paths. A solution called My-App got directories called My-App.Worker and a solution referencing My_App.Worker, and failed to build; 123orders failed the same way. Only a name that was already a valid identifier path worked, because for those the two forms are identical. sourceName is replaced by one generated symbol carrying both replaces and fileRename, so paths and contents get the same sanitised value. Sanitisation now covers leading digits as well as characters outside an identifier. Verified across both archetypes and three name shapes — Acme.Billing, My-App and 123orders — all building with no errors and passing their tests. Acme.Billing is unchanged. The manifest test required sourceName in every template. It now asserts exactly one symbol renames files and that sourceName is absent, so the two mechanisms cannot be reintroduced alongside each other. --- .../loom-api/.template.config/template.json | 25 ++++++++++++++++++- .../.template.config/template.json | 25 ++++++++++++++++++- .../TemplateManifestTests.cs | 18 ++++++++++--- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/Loom.Templates/templates/loom-api/.template.config/template.json b/src/Loom.Templates/templates/loom-api/.template.config/template.json index 225b04e..309f1dd 100644 --- a/src/Loom.Templates/templates/loom-api/.template.config/template.json +++ b/src/Loom.Templates/templates/loom-api/.template.config/template.json @@ -15,10 +15,29 @@ "language": "C#", "type": "solution" }, - "sourceName": "MyApp", "preferNameDirectory": true, "defaultName": "MyApp", "symbols": { + "safeName": { + "type": "generated", + "generator": "regex", + "dataType": "string", + "replaces": "MyApp", + "fileRename": "MyApp", + "parameters": { + "source": "name", + "steps": [ + { + "regex": "[^A-Za-z0-9_.]", + "replacement": "_" + }, + { + "regex": "(^|\\.)([0-9])", + "replacement": "$1_$2" + } + ] + } + }, "userSecretsId": { "type": "generated", "generator": "guid", @@ -38,6 +57,10 @@ { "regex": "[^A-Za-z0-9_]", "replacement": "_" + }, + { + "regex": "^([0-9])", + "replacement": "_$1" } ] } diff --git a/src/Loom.Templates/templates/loom-worker/.template.config/template.json b/src/Loom.Templates/templates/loom-worker/.template.config/template.json index b9db89b..d2e2550 100644 --- a/src/Loom.Templates/templates/loom-worker/.template.config/template.json +++ b/src/Loom.Templates/templates/loom-worker/.template.config/template.json @@ -15,10 +15,29 @@ "language": "C#", "type": "solution" }, - "sourceName": "MyApp", "preferNameDirectory": true, "defaultName": "MyApp", "symbols": { + "safeName": { + "type": "generated", + "generator": "regex", + "dataType": "string", + "replaces": "MyApp", + "fileRename": "MyApp", + "parameters": { + "source": "name", + "steps": [ + { + "regex": "[^A-Za-z0-9_.]", + "replacement": "_" + }, + { + "regex": "(^|\\.)([0-9])", + "replacement": "$1_$2" + } + ] + } + }, "userSecretsId": { "type": "generated", "generator": "guid", @@ -38,6 +57,10 @@ { "regex": "[^A-Za-z0-9_]", "replacement": "_" + }, + { + "regex": "^([0-9])", + "replacement": "_$1" } ] } diff --git a/tests/Loom.Templates.Tests/TemplateManifestTests.cs b/tests/Loom.Templates.Tests/TemplateManifestTests.cs index bcd5a3e..bb4157d 100644 --- a/tests/Loom.Templates.Tests/TemplateManifestTests.cs +++ b/tests/Loom.Templates.Tests/TemplateManifestTests.cs @@ -33,7 +33,7 @@ public async Task The_Manifest_Is_Valid_Json_With_The_Members_Dotnet_New_Require { JsonElement manifest = await ReadManifestAsync(template); - foreach (string member in (string[])["identity", "name", "shortName", "sourceName"]) + foreach (string member in (string[])["identity", "name", "shortName"]) { await Assert.That(manifest.TryGetProperty(member, out JsonElement value) && value.ValueKind is JsonValueKind.String && @@ -53,12 +53,22 @@ public async Task The_Short_Name_Matches_The_Directory(string template) [Test] [MethodDataSource(nameof(Templates))] - public async Task The_Source_Name_Occurs_In_The_Content_It_Renames(string template) + public async Task Exactly_One_Symbol_Renames_Files(string template) { JsonElement manifest = await ReadManifestAsync(template); - string sourceName = manifest.GetProperty("sourceName").GetString()!; - await Assert.That(await OccurrencesAsync(template, sourceName)).IsGreaterThan(0); + string[] renamers = + [ + .. manifest.GetProperty("symbols").EnumerateObject() + .Where(symbol => symbol.Value.TryGetProperty("fileRename", out _)) + .Select(symbol => symbol.Name), + ]; + + // The token is renamed and replaced by one symbol rather than by sourceName, so that paths and + // file contents get the same sanitised value. A name like "My-App" is a valid directory and an + // invalid identifier; two mechanisms would disagree about which to write where. + await Assert.That(renamers.Length).IsEqualTo(1); + await Assert.That(manifest.TryGetProperty("sourceName", out _)).IsFalse(); } [Test] From a238255a8db792db764c91c9fb35e4017a5138d0 Mon Sep 17 00:00:00 2001 From: Dylan de Beer Date: Fri, 31 Jul 2026 08:36:35 +0200 Subject: [PATCH 2/2] refactor(templates): hide the members that are only implementation details Configure and CreateDbContext are called through their interfaces, by EF and by the migration tooling, and never directly. Explicit implementations take them off the public surface rather than documenting members that should not be on it. Widgets is genuinely public and says what it is. Confirmed the tooling still resolves the explicit factory: dotnet ef migrations list against a scaffolded worker returns its initial migration. --- .../src/MyApp.Api/Features/Widgets/WidgetConfiguration.cs | 2 +- .../loom-api/src/MyApp.Api/Infrastructure/AppDbContext.cs | 1 + .../src/MyApp.Api/Infrastructure/DesignTimeDbContextFactory.cs | 2 +- .../src/MyApp.Worker/Features/Widgets/WidgetConfiguration.cs | 2 +- .../loom-worker/src/MyApp.Worker/Infrastructure/AppDbContext.cs | 1 + .../MyApp.Worker/Infrastructure/DesignTimeDbContextFactory.cs | 2 +- 6 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Features/Widgets/WidgetConfiguration.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Features/Widgets/WidgetConfiguration.cs index 9edbdb5..66fa536 100644 --- a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Features/Widgets/WidgetConfiguration.cs +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Features/Widgets/WidgetConfiguration.cs @@ -14,7 +14,7 @@ namespace MyApp.Api.Features.Widgets; /// internal sealed class WidgetConfiguration : IEntityTypeConfiguration { - public void Configure(EntityTypeBuilder builder) + void IEntityTypeConfiguration.Configure(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/AppDbContext.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/AppDbContext.cs index cb26654..f35f8cd 100644 --- a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/AppDbContext.cs +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/AppDbContext.cs @@ -9,6 +9,7 @@ namespace MyApp.Api.Infrastructure; /// public sealed class AppDbContext(DbContextOptions options) : DbContext(options) { + /// Gets the widgets. Only aggregate roots get a set of their own. public DbSet Widgets => Set(); // Conventions, not model creation. Property discovery skips types it does not recognise, so an diff --git a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/DesignTimeDbContextFactory.cs b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/DesignTimeDbContextFactory.cs index 8dd2981..996fbb7 100644 --- a/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/DesignTimeDbContextFactory.cs +++ b/src/Loom.Templates/templates/loom-api/src/MyApp.Api/Infrastructure/DesignTimeDbContextFactory.cs @@ -12,7 +12,7 @@ namespace MyApp.Api.Infrastructure; /// internal sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory { - public AppDbContext CreateDbContext(string[] args) + AppDbContext IDesignTimeDbContextFactory.CreateDbContext(string[] args) { DbContextOptionsBuilder builder = new(); builder.UseNpgsql("Host=design-time;Database=designtime;Username=none;Password=none"); diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Features/Widgets/WidgetConfiguration.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Features/Widgets/WidgetConfiguration.cs index b0a1129..1633b28 100644 --- a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Features/Widgets/WidgetConfiguration.cs +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Features/Widgets/WidgetConfiguration.cs @@ -13,7 +13,7 @@ namespace MyApp.Worker.Features.Widgets; /// internal sealed class WidgetConfiguration : IEntityTypeConfiguration { - public void Configure(EntityTypeBuilder builder) + void IEntityTypeConfiguration.Configure(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Infrastructure/AppDbContext.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Infrastructure/AppDbContext.cs index fa26804..69be7ed 100644 --- a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Infrastructure/AppDbContext.cs +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Infrastructure/AppDbContext.cs @@ -9,6 +9,7 @@ namespace MyApp.Worker.Infrastructure; /// public sealed class AppDbContext(DbContextOptions options) : DbContext(options) { + /// Gets the widgets. Only aggregate roots get a set of their own. public DbSet Widgets => Set(); // Conventions, not model creation. Property discovery skips types it does not recognise, so an diff --git a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Infrastructure/DesignTimeDbContextFactory.cs b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Infrastructure/DesignTimeDbContextFactory.cs index e3071d2..4cf4a78 100644 --- a/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Infrastructure/DesignTimeDbContextFactory.cs +++ b/src/Loom.Templates/templates/loom-worker/src/MyApp.Worker/Infrastructure/DesignTimeDbContextFactory.cs @@ -12,7 +12,7 @@ namespace MyApp.Worker.Infrastructure; /// internal sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory { - public AppDbContext CreateDbContext(string[] args) + AppDbContext IDesignTimeDbContextFactory.CreateDbContext(string[] args) { DbContextOptionsBuilder builder = new(); builder.UseNpgsql("Host=design-time;Database=designtime;Username=none;Password=none");