@atlashub/smartstack-cli 3.13.0 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/index.js +26 -28
  2. package/dist/index.js.map +1 -1
  3. package/dist/mcp-entry.mjs +626 -141
  4. package/dist/mcp-entry.mjs.map +1 -1
  5. package/package.json +1 -1
  6. package/templates/agents/efcore/migration.md +15 -0
  7. package/templates/skills/apex/steps/step-04-validate.md +64 -5
  8. package/templates/skills/application/references/frontend-verification.md +20 -0
  9. package/templates/skills/application/steps/step-04-backend.md +17 -1
  10. package/templates/skills/application/steps/step-05-frontend.md +49 -23
  11. package/templates/skills/application/templates-seed.md +14 -4
  12. package/templates/skills/business-analyse/SKILL.md +3 -2
  13. package/templates/skills/business-analyse/_module-loop.md +5 -5
  14. package/templates/skills/business-analyse/html/ba-interactive.html +165 -0
  15. package/templates/skills/business-analyse/html/src/scripts/01-data-init.js +2 -0
  16. package/templates/skills/business-analyse/html/src/scripts/06-render-consolidation.js +85 -0
  17. package/templates/skills/business-analyse/html/src/styles/05-modules.css +65 -0
  18. package/templates/skills/business-analyse/html/src/template.html +13 -0
  19. package/templates/skills/business-analyse/questionnaire.md +1 -1
  20. package/templates/skills/business-analyse/references/cache-warming-strategy.md +11 -23
  21. package/templates/skills/business-analyse/references/cadrage-pre-analysis.md +112 -0
  22. package/templates/skills/business-analyse/references/cadrage-structure-cards.md +6 -1
  23. package/templates/skills/business-analyse/references/deploy-data-build.md +1 -1
  24. package/templates/skills/business-analyse/references/html-data-mapping.md +1 -1
  25. package/templates/skills/business-analyse/references/robustness-checks.md +1 -1
  26. package/templates/skills/business-analyse/references/spec-auto-inference.md +1 -1
  27. package/templates/skills/business-analyse/schemas/application-schema.json +38 -1
  28. package/templates/skills/business-analyse/schemas/sections/metadata-schema.json +2 -1
  29. package/templates/skills/business-analyse/steps/step-00-init.md +18 -22
  30. package/templates/skills/business-analyse/steps/step-01-cadrage.md +383 -128
  31. package/templates/skills/business-analyse/steps/step-02-decomposition.md +42 -16
  32. package/templates/skills/business-analyse/steps/step-03a-data.md +5 -31
  33. package/templates/skills/business-analyse/steps/step-03a1-setup.md +41 -2
  34. package/templates/skills/business-analyse/steps/step-03b-ui.md +20 -11
  35. package/templates/skills/business-analyse/steps/step-03d-validate.md +6 -6
  36. package/templates/skills/business-analyse/steps/step-04-consolidation.md +5 -31
  37. package/templates/skills/business-analyse/steps/step-04c-decide.md +1 -1
  38. package/templates/skills/business-analyse/steps/step-05a-handoff.md +1 -1
  39. package/templates/skills/business-analyse/steps/step-05b-deploy.md +3 -3
  40. package/templates/skills/business-analyse/steps/step-05c-ralph-readiness.md +1 -1
  41. package/templates/skills/business-analyse/templates/tpl-frd.md +1 -1
  42. package/templates/skills/efcore/steps/shared/step-00-init.md +55 -0
  43. package/templates/skills/ralph-loop/SKILL.md +1 -0
  44. package/templates/skills/ralph-loop/references/category-rules.md +131 -27
  45. package/templates/skills/ralph-loop/references/compact-loop.md +61 -3
  46. package/templates/skills/ralph-loop/references/core-seed-data.md +251 -5
  47. package/templates/skills/ralph-loop/references/error-classification.md +143 -0
  48. package/templates/skills/ralph-loop/steps/step-05-report.md +54 -0
  49. package/templates/skills/review-code/references/smartstack-conventions.md +16 -0
  50. package/templates/skills/validate-feature/SKILL.md +11 -1
  51. package/templates/skills/validate-feature/steps/step-00-dependencies.md +121 -0
  52. package/templates/skills/validate-feature/steps/step-04-api-smoke.md +61 -13
  53. package/templates/skills/validate-feature/steps/step-05-db-validation.md +250 -0
  54. package/templates/skills/business-analyse/references/cadrage-vibe-coding.md +0 -87
@@ -25799,6 +25799,7 @@ var init_types3 = __esm({
25799
25799
  "loc_",
25800
25800
  "lic_"
25801
25801
  ]),
25802
+ customTablePrefixes: external_exports.array(external_exports.string()).default([]),
25802
25803
  scopeTypes: external_exports.array(external_exports.string()).default(["Core", "Extension", "Partner", "Community"]),
25803
25804
  migrationFormat: external_exports.string().default("{context}_v{version}_{sequence}_{Description}"),
25804
25805
  namespaces: external_exports.object({
@@ -25996,6 +25997,7 @@ var init_types3 = __esm({
25996
25997
  includeLayouts: external_exports.boolean().default(true).describe("Generate layout components"),
25997
25998
  includeGuards: external_exports.boolean().default(true).describe("Include route guards for permissions"),
25998
25999
  generateRegistry: external_exports.boolean().default(true).describe("Generate navRoutes.generated.ts"),
26000
+ outputFormat: external_exports.enum(["standalone", "clientRoutes"]).default("standalone").describe('Output format: "standalone" generates createBrowserRouter(), "clientRoutes" generates exportable RouteObject[] arrays for App.tsx integration'),
25999
26001
  dryRun: external_exports.boolean().default(false).describe("Preview without writing files")
26000
26002
  }).optional()
26001
26003
  });
@@ -26356,6 +26358,7 @@ var init_config = __esm({
26356
26358
  "lic_",
26357
26359
  "tenant_"
26358
26360
  ],
26361
+ customTablePrefixes: [],
26359
26362
  scopeTypes: ["Core", "Extension", "Partner", "Community"],
26360
26363
  migrationFormat: "{context}_v{version}_{sequence}_{Description}",
26361
26364
  namespaces: {
@@ -26698,7 +26701,7 @@ async function validateTablePrefixes(structure, config2, result) {
26698
26701
  cwd: structure.infrastructure
26699
26702
  });
26700
26703
  const validSchemas = [config2.conventions.schemas.platform, config2.conventions.schemas.extensions];
26701
- const validPrefixes = config2.conventions.tablePrefixes;
26704
+ const validPrefixes = [...config2.conventions.tablePrefixes, ...config2.conventions.customTablePrefixes || []];
26702
26705
  const schemaConstantsMap = {
26703
26706
  "SchemaConstants.Core": config2.conventions.schemas.platform,
26704
26707
  "SchemaConstants.Extensions": config2.conventions.schemas.extensions
@@ -52579,7 +52582,8 @@ async function scaffoldControllerTests(name, options, testTypes, structure, conf
52579
52582
  });
52580
52583
  }
52581
52584
  result.instructions.push(`Add package reference: <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.*" />`);
52582
- result.instructions.push(`Add package reference: <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.*" />`);
52585
+ result.instructions.push(`Add package reference: <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.*" />`);
52586
+ result.instructions.push(`Add package reference: <PackageReference Include="Respawn" Version="4.*" />`);
52583
52587
  }
52584
52588
  async function scaffoldValidatorTests(name, options, testTypes, structure, config2, result, dryRun) {
52585
52589
  const testNamespace = `${config2.conventions.namespaces.application}.Tests`;
@@ -52631,7 +52635,8 @@ async function scaffoldRepositoryTests(name, options, testTypes, structure, conf
52631
52635
  type: "created"
52632
52636
  });
52633
52637
  }
52634
- result.instructions.push(`Add package reference: <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.*" />`);
52638
+ result.instructions.push(`Add package reference: <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.*" />`);
52639
+ result.instructions.push(`Add package reference: <PackageReference Include="Respawn" Version="4.*" />`);
52635
52640
  }
52636
52641
  async function scaffoldInfrastructureTests(name, options, structure, config2, result, dryRun) {
52637
52642
  const testNamespace = `${config2.conventions.namespaces.application}.Tests`;
@@ -52640,11 +52645,16 @@ async function scaffoldInfrastructureTests(name, options, structure, config2, re
52640
52645
  name,
52641
52646
  testNamespace,
52642
52647
  infrastructureNamespace,
52648
+ applicationNamespace: config2.conventions.namespaces.application,
52649
+ domainNamespace: config2.conventions.namespaces.domain,
52643
52650
  ...options
52644
52651
  };
52645
52652
  const templates = [
52653
+ { template: databaseFixtureTemplate, filename: "DatabaseFixture.cs" },
52654
+ { template: databaseCollectionTemplate, filename: "DatabaseCollection.cs" },
52646
52655
  { template: testFactoryTemplate, filename: "SmartStackTestFactory.cs" },
52647
52656
  { template: testAuthHandlerTemplate, filename: "TestAuthHandler.cs" },
52657
+ { template: testTenantServiceTemplate, filename: "TestTenantService.cs" },
52648
52658
  { template: integrationTestBaseTemplate, filename: "IntegrationTestBase.cs" },
52649
52659
  { template: testDataSeederTemplate, filename: "TestDataSeeder.cs" }
52650
52660
  ];
@@ -52663,10 +52673,12 @@ async function scaffoldInfrastructureTests(name, options, structure, config2, re
52663
52673
  });
52664
52674
  }
52665
52675
  result.instructions.push(`Add package reference: <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.*" />`);
52666
- result.instructions.push(`Add package reference: <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.*" />`);
52667
- result.instructions.push(`Add package reference: <PackageReference Include="Microsoft.Data.Sqlite" Version="9.*" />`);
52676
+ result.instructions.push(`Add package reference: <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.*" />`);
52677
+ result.instructions.push(`Add package reference: <PackageReference Include="Microsoft.Data.SqlClient" Version="6.*" />`);
52678
+ result.instructions.push(`Add package reference: <PackageReference Include="Respawn" Version="4.*" />`);
52668
52679
  result.instructions.push(`Add package reference: <PackageReference Include="FluentAssertions" Version="8.*" />`);
52669
52680
  result.instructions.push(`IMPORTANT: Add to your API .csproj: <InternalsVisibleTo Include="$(AssemblyName).Tests" /> or add 'public partial class Program { }' to Program.cs`);
52681
+ result.instructions.push(`IMPORTANT: SQL Server LocalDB must be installed. Run 'sqllocaldb info MSSQLLocalDB' to verify.`);
52670
52682
  }
52671
52683
  function formatTestResult(result, _target, name, dryRun) {
52672
52684
  const lines = [];
@@ -52709,7 +52721,7 @@ function formatTestResult(result, _target, name, dryRun) {
52709
52721
  lines.push("");
52710
52722
  return lines.join("\n");
52711
52723
  }
52712
- var import_handlebars2, scaffoldTestsTool, entityTestTemplate, serviceTestTemplate, controllerTestTemplate, validatorTestTemplate, repositoryTestTemplate, securityTestTemplate, testFactoryTemplate, testAuthHandlerTemplate, integrationTestBaseTemplate, testDataSeederTemplate, controllerIntegrationTestTemplate;
52724
+ var import_handlebars2, scaffoldTestsTool, entityTestTemplate, serviceTestTemplate, controllerTestTemplate, validatorTestTemplate, repositoryTestTemplate, securityTestTemplate, testFactoryTemplate, databaseFixtureTemplate, databaseCollectionTemplate, testTenantServiceTemplate, testAuthHandlerTemplate, integrationTestBaseTemplate, testDataSeederTemplate, controllerIntegrationTestTemplate;
52713
52725
  var init_scaffold_tests = __esm({
52714
52726
  "src/mcp/tools/scaffold-tests.ts"() {
52715
52727
  "use strict";
@@ -53676,6 +53688,7 @@ using System.Threading.Tasks;
53676
53688
  using FluentAssertions;
53677
53689
  using Microsoft.EntityFrameworkCore;
53678
53690
  using Xunit;
53691
+ using {{testNamespace}}.Integration;
53679
53692
  using {{infrastructureNamespace}}.Persistence;
53680
53693
  using {{infrastructureNamespace}}.Repositories;
53681
53694
  using {{domainNamespace}};
@@ -53683,30 +53696,35 @@ using {{domainNamespace}};
53683
53696
  namespace {{testNamespace}}.Integration.Repositories;
53684
53697
 
53685
53698
  /// <summary>
53686
- /// Integration tests for {{name}}Repository
53687
- /// Tests: CRUD with DbContext, Queries, Tenant scope
53699
+ /// Integration tests for {{name}}Repository against REAL SQL Server LocalDB.
53700
+ /// Uses DatabaseFixture for real migrations + Respawn for fast reset.
53688
53701
  /// </summary>
53689
- public class {{name}}RepositoryTests : IDisposable
53702
+ [Collection(DatabaseCollection.Name)]
53703
+ public class {{name}}RepositoryTests : IAsyncLifetime
53690
53704
  {
53691
- private readonly ApplicationDbContext _context;
53692
- private readonly {{name}}Repository _repository;
53705
+ private readonly DatabaseFixture _database;
53706
+ private CoreDbContext _context = null!;
53707
+ private {{name}}Repository _repository = null!;
53693
53708
  {{#unless isSystemEntity}}
53694
- private readonly Guid _tenantId = Guid.NewGuid();
53709
+ private readonly Guid _tenantId = Guid.Parse("11111111-1111-1111-1111-111111111111");
53695
53710
  {{/unless}}
53696
53711
 
53697
- public {{name}}RepositoryTests()
53712
+ public {{name}}RepositoryTests(DatabaseFixture database)
53698
53713
  {
53699
- var options = new DbContextOptionsBuilder<ApplicationDbContext>()
53700
- .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
53701
- .Options;
53714
+ _database = database;
53715
+ }
53702
53716
 
53703
- _context = new ApplicationDbContext(options);
53717
+ public async ValueTask InitializeAsync()
53718
+ {
53719
+ await _database.ResetDatabaseAsync();
53720
+ _context = _database.CreateDbContext();
53704
53721
  _repository = new {{name}}Repository(_context);
53705
53722
  }
53706
53723
 
53707
- public void Dispose()
53724
+ public async ValueTask DisposeAsync()
53708
53725
  {
53709
- _context.Dispose();
53726
+ if (_context is not null)
53727
+ await _context.DisposeAsync();
53710
53728
  }
53711
53729
 
53712
53730
  #region GetByIdAsync Tests
@@ -53754,9 +53772,10 @@ public class {{name}}RepositoryTests : IDisposable
53754
53772
  var result = await _repository.AddAsync(entity);
53755
53773
  await _context.SaveChangesAsync();
53756
53774
 
53757
- // Assert
53775
+ // Assert \u2014 verify REAL SQL Server persistence
53758
53776
  result.Should().NotBeNull();
53759
- var persisted = await _context.Set<{{name}}>().FindAsync(entity.Id);
53777
+ await using var verifyCtx = _database.CreateDbContext();
53778
+ var persisted = await verifyCtx.Set<{{name}}>().FindAsync(entity.Id);
53760
53779
  persisted.Should().NotBeNull();
53761
53780
  }
53762
53781
 
@@ -53777,8 +53796,9 @@ public class {{name}}RepositoryTests : IDisposable
53777
53796
  await _repository.UpdateAsync(entity);
53778
53797
  await _context.SaveChangesAsync();
53779
53798
 
53780
- // Assert
53781
- var updated = await _context.Set<{{name}}>().FindAsync(entity.Id);
53799
+ // Assert \u2014 verify REAL SQL Server persistence
53800
+ await using var verifyCtx = _database.CreateDbContext();
53801
+ var updated = await verifyCtx.Set<{{name}}>().FindAsync(entity.Id);
53782
53802
  updated!.UpdatedBy.Should().Be("updater");
53783
53803
  }
53784
53804
 
@@ -53798,15 +53818,16 @@ public class {{name}}RepositoryTests : IDisposable
53798
53818
  await _repository.DeleteAsync(entity);
53799
53819
  await _context.SaveChangesAsync();
53800
53820
 
53801
- // Assert
53802
- var deleted = await _context.Set<{{name}}>().FindAsync(entity.Id);
53821
+ // Assert \u2014 verify REAL SQL Server deletion
53822
+ await using var verifyCtx = _database.CreateDbContext();
53823
+ var deleted = await verifyCtx.Set<{{name}}>().FindAsync(entity.Id);
53803
53824
  deleted.Should().BeNull();
53804
53825
  }
53805
53826
 
53806
53827
  #endregion
53807
53828
 
53808
53829
  {{#unless isSystemEntity}}
53809
- #region Tenant Isolation Tests
53830
+ #region Tenant Isolation Tests (REAL SQL Server)
53810
53831
 
53811
53832
  [Fact]
53812
53833
  public async Task GetAllByTenantAsync_ShouldOnlyReturnTenantEntities()
@@ -53823,7 +53844,7 @@ public class {{name}}RepositoryTests : IDisposable
53823
53844
  // Act
53824
53845
  var result = await _repository.GetAllByTenantAsync(_tenantId);
53825
53846
 
53826
- // Assert
53847
+ // Assert \u2014 tenant isolation enforced at SQL Server level
53827
53848
  result.Should().HaveCount(2);
53828
53849
  result.Should().OnlyContain(e => e.TenantId == _tenantId);
53829
53850
  }
@@ -53848,7 +53869,7 @@ public class {{name}}RepositoryTests : IDisposable
53848
53869
  {{/unless}}
53849
53870
 
53850
53871
  {{#if includeSoftDelete}}
53851
- #region Soft Delete Tests
53872
+ #region Soft Delete Tests (REAL SQL Server)
53852
53873
 
53853
53874
  [Fact]
53854
53875
  public async Task GetAllAsync_ShouldExcludeSoftDeletedEntities()
@@ -53864,7 +53885,7 @@ public class {{name}}RepositoryTests : IDisposable
53864
53885
  // Act
53865
53886
  var result = await _repository.GetAllAsync();
53866
53887
 
53867
- // Assert
53888
+ // Assert \u2014 soft delete filter verified at SQL Server level
53868
53889
  result.Should().ContainSingle();
53869
53890
  result.First().Code.Should().Be("active");
53870
53891
  }
@@ -54086,96 +54107,245 @@ public class {{name}}SecurityTests : IClassFixture<WebApplicationFactory<Program
54086
54107
  #endregion
54087
54108
  }
54088
54109
  `;
54089
- testFactoryTemplate = `using System;
54090
- using System.Data.Common;
54091
- using System.Linq;
54092
- using Microsoft.AspNetCore.Authentication;
54093
- using Microsoft.AspNetCore.Hosting;
54110
+ testFactoryTemplate = `using Microsoft.AspNetCore.Hosting;
54094
54111
  using Microsoft.AspNetCore.Mvc.Testing;
54095
- using Microsoft.Data.Sqlite;
54112
+ using Microsoft.AspNetCore.TestHost;
54096
54113
  using Microsoft.EntityFrameworkCore;
54114
+ using Microsoft.Extensions.Configuration;
54097
54115
  using Microsoft.Extensions.DependencyInjection;
54116
+ using Microsoft.Extensions.DependencyInjection.Extensions;
54117
+ using Microsoft.Extensions.Hosting;
54098
54118
  using {{infrastructureNamespace}}.Persistence;
54099
54119
 
54100
54120
  namespace {{testNamespace}}.Integration;
54101
54121
 
54102
54122
  /// <summary>
54103
- /// Shared WebApplicationFactory for integration tests.
54104
- /// Uses SQLite in-memory for real SQL behavior.
54105
- /// Replaces JWT auth with TestAuthHandler.
54123
+ /// Custom WebApplicationFactory that redirects the API to use a test LocalDB database.
54124
+ /// Uses REAL SQL Server (LocalDB) \u2014 not SQLite. Validates real migrations, real LINQ\u2192SQL.
54106
54125
  /// </summary>
54107
- public class SmartStackTestFactory : WebApplicationFactory<Program>, IAsyncLifetime
54126
+ public class SmartStackTestFactory : WebApplicationFactory<Program>
54108
54127
  {
54109
- private DbConnection _connection = null!;
54128
+ private readonly string _connectionString;
54110
54129
 
54111
- protected override void ConfigureWebHost(IWebHostBuilder builder)
54130
+ public SmartStackTestFactory(string connectionString)
54112
54131
  {
54113
- builder.ConfigureServices(services =>
54132
+ _connectionString = connectionString;
54133
+ }
54134
+
54135
+ protected override IHost CreateHost(IHostBuilder builder)
54136
+ {
54137
+ builder.ConfigureHostConfiguration(config =>
54114
54138
  {
54115
- // Remove existing DbContext registration
54116
- var dbDescriptor = services.SingleOrDefault(
54117
- d => d.ServiceType == typeof(DbContextOptions<ApplicationDbContext>));
54118
- if (dbDescriptor != null)
54119
- services.Remove(dbDescriptor);
54120
-
54121
- // Remove existing DbConnection if registered
54122
- var connDescriptor = services.SingleOrDefault(
54123
- d => d.ServiceType == typeof(DbConnection));
54124
- if (connDescriptor != null)
54125
- services.Remove(connDescriptor);
54126
-
54127
- // Create and open a shared SQLite in-memory connection
54128
- _connection = new SqliteConnection("DataSource=:memory:");
54129
- _connection.Open();
54130
-
54131
- services.AddSingleton(_connection);
54132
- services.AddDbContext<ApplicationDbContext>((sp, options) =>
54139
+ config.AddInMemoryCollection(new Dictionary<string, string?>
54133
54140
  {
54134
- options.UseSqlite(sp.GetRequiredService<DbConnection>());
54141
+ ["ConnectionStrings:DefaultConnection"] = _connectionString,
54142
+ ["Jwt:Secret"] = "IntegrationTestSecretKeyThatIsAtLeast256BitsLongForHmacSha256Validation!",
54143
+ ["Jwt:Issuer"] = "SmartStack",
54144
+ ["Jwt:Audience"] = "SmartStack",
54145
+ ["Jwt:AccessTokenExpirationMinutes"] = "60",
54135
54146
  });
54147
+ });
54136
54148
 
54137
- // Replace authentication with test handler
54138
- services.AddAuthentication("Test")
54139
- .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", null);
54149
+ return base.CreateHost(builder);
54150
+ }
54151
+
54152
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
54153
+ {
54154
+ builder.UseEnvironment("Development");
54155
+
54156
+ builder.ConfigureAppConfiguration((_, config) =>
54157
+ {
54158
+ config.AddInMemoryCollection(new Dictionary<string, string?>
54159
+ {
54160
+ ["ConnectionStrings:DefaultConnection"] = _connectionString,
54161
+ ["Jwt:Secret"] = "IntegrationTestSecretKeyThatIsAtLeast256BitsLongForHmacSha256Validation!",
54162
+ ["Jwt:Issuer"] = "SmartStack",
54163
+ ["Jwt:Audience"] = "SmartStack",
54164
+ ["Jwt:AccessTokenExpirationMinutes"] = "60",
54165
+ ["Jwt:RefreshTokenExpirationDays"] = "7",
54166
+ });
54140
54167
  });
54141
54168
 
54142
- builder.UseEnvironment("Testing");
54169
+ builder.ConfigureTestServices(services =>
54170
+ {
54171
+ // Remove existing DbContext registrations
54172
+ services.RemoveAll<DbContextOptions<CoreDbContext>>();
54173
+
54174
+ // Re-register with test SQL Server connection string
54175
+ services.AddDbContext<CoreDbContext>(options =>
54176
+ {
54177
+ options.UseSqlServer(_connectionString, b =>
54178
+ {
54179
+ b.MigrationsAssembly(typeof(CoreDbContext).Assembly.FullName);
54180
+ });
54181
+ });
54182
+
54183
+ services.AddScoped<ICoreDbContext>(provider =>
54184
+ provider.GetRequiredService<CoreDbContext>());
54185
+ });
54143
54186
  }
54187
+ }
54188
+ `;
54189
+ databaseFixtureTemplate = `using System.Data.Common;
54190
+ using Microsoft.Data.SqlClient;
54191
+ using Microsoft.EntityFrameworkCore;
54192
+ using Microsoft.Extensions.Logging.Abstractions;
54193
+ using Respawn;
54194
+ using {{applicationNamespace}}.Common.Interfaces.Tenants;
54195
+ using {{infrastructureNamespace}}.Persistence;
54196
+
54197
+ namespace {{testNamespace}}.Integration;
54198
+
54199
+ /// <summary>
54200
+ /// Shared fixture that manages a LocalDB test database lifecycle.
54201
+ /// Created once per test session, reset with Respawn between tests (~50ms).
54202
+ /// Applies REAL EF Core migrations \u2014 validates migration chain integrity.
54203
+ /// </summary>
54204
+ public class DatabaseFixture : IAsyncLifetime
54205
+ {
54206
+ private const string LocalDbInstance = "MSSQLLocalDB";
54207
+ private readonly string _databaseName = $"SmartStack_IntTests_{Guid.NewGuid():N}";
54208
+ private Respawner _respawner = null!;
54209
+ private DbConnection _connection = null!;
54210
+
54211
+ public string ConnectionString { get; private set; } = null!;
54144
54212
 
54145
- public async Task InitializeAsync()
54213
+ public async ValueTask InitializeAsync()
54146
54214
  {
54147
- // Ensure database is created with current schema
54148
- using var scope = Services.CreateScope();
54149
- var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
54150
- await db.Database.EnsureCreatedAsync();
54215
+ ConnectionString = $"Server=(localdb)\\\\{LocalDbInstance};Database={_databaseName};Integrated Security=true;TrustServerCertificate=true;Connect Timeout=120;";
54151
54216
 
54152
- // Seed minimal test data (tenant, admin user, base permissions)
54153
- var seeder = new TestDataSeeder(db);
54154
- await seeder.SeedAsync();
54217
+ await CreateDatabaseAsync();
54218
+ await ApplyMigrationsAsync();
54219
+ await InitializeRespawnerAsync();
54155
54220
  }
54156
54221
 
54157
- async Task IAsyncLifetime.DisposeAsync()
54222
+ public async ValueTask DisposeAsync()
54158
54223
  {
54159
54224
  if (_connection is not null)
54160
- {
54161
- await _connection.CloseAsync();
54162
54225
  await _connection.DisposeAsync();
54163
- }
54226
+
54227
+ await DropDatabaseAsync();
54164
54228
  }
54165
54229
 
54166
54230
  /// <summary>
54167
- /// Resets the database between test classes for isolation.
54231
+ /// Resets all data in the test database (~50ms). Call before each test.
54168
54232
  /// </summary>
54169
54233
  public async Task ResetDatabaseAsync()
54170
54234
  {
54171
- using var scope = Services.CreateScope();
54172
- var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
54173
- await db.Database.EnsureDeletedAsync();
54174
- await db.Database.EnsureCreatedAsync();
54235
+ await _respawner.ResetAsync(_connection);
54236
+ }
54237
+
54238
+ /// <summary>
54239
+ /// Creates a CoreDbContext connected to the test database.
54240
+ /// Pass a tenantService to enable tenant query filters, or null for global scope.
54241
+ /// </summary>
54242
+ public CoreDbContext CreateDbContext(ICurrentTenantService? tenantService = null)
54243
+ {
54244
+ var options = new DbContextOptionsBuilder<CoreDbContext>()
54245
+ .UseSqlServer(ConnectionString, b =>
54246
+ {
54247
+ b.MigrationsAssembly(typeof(CoreDbContext).Assembly.FullName);
54248
+ })
54249
+ .Options;
54250
+
54251
+ return new CoreDbContext(options, tenantService, null, NullLogger<CoreDbContext>.Instance);
54252
+ }
54253
+
54254
+ private async Task CreateDatabaseAsync()
54255
+ {
54256
+ var masterCs = $"Server=(localdb)\\\\{LocalDbInstance};Database=master;Integrated Security=true;TrustServerCertificate=true;Connect Timeout=120;";
54257
+ await using var conn = new SqlConnection(masterCs);
54258
+ await conn.OpenAsync();
54259
+
54260
+ await using var cmd = conn.CreateCommand();
54261
+ cmd.CommandText = $"CREATE DATABASE [{_databaseName}]";
54262
+ await cmd.ExecuteNonQueryAsync();
54263
+ }
54264
+
54265
+ private async Task ApplyMigrationsAsync()
54266
+ {
54267
+ await using var context = CreateDbContext();
54268
+ await context.Database.MigrateAsync();
54269
+ }
54270
+
54271
+ private async Task InitializeRespawnerAsync()
54272
+ {
54273
+ _connection = new SqlConnection(ConnectionString);
54274
+ await _connection.OpenAsync();
54275
+
54276
+ _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
54277
+ {
54278
+ DbAdapter = DbAdapter.SqlServer,
54279
+ SchemasToInclude = ["core"],
54280
+ TablesToIgnore = [new Respawn.Graph.Table("__EFMigrationsHistory")],
54281
+ WithReseed = true
54282
+ });
54283
+ }
54284
+
54285
+ private async Task DropDatabaseAsync()
54286
+ {
54287
+ var masterCs = $"Server=(localdb)\\\\{LocalDbInstance};Database=master;Integrated Security=true;TrustServerCertificate=true;Connect Timeout=120;";
54288
+ await using var conn = new SqlConnection(masterCs);
54289
+ await conn.OpenAsync();
54290
+
54291
+ await using var cmd1 = conn.CreateCommand();
54292
+ cmd1.CommandText = $"IF DB_ID('{_databaseName}') IS NOT NULL ALTER DATABASE [{_databaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE";
54293
+ await cmd1.ExecuteNonQueryAsync();
54294
+
54295
+ await using var cmd2 = conn.CreateCommand();
54296
+ cmd2.CommandText = $"IF DB_ID('{_databaseName}') IS NOT NULL DROP DATABASE [{_databaseName}]";
54297
+ await cmd2.ExecuteNonQueryAsync();
54298
+ }
54299
+ }
54300
+ `;
54301
+ databaseCollectionTemplate = `namespace {{testNamespace}}.Integration;
54302
+
54303
+ /// <summary>
54304
+ /// xUnit collection definition. All tests in this collection share the same DatabaseFixture.
54305
+ /// Database is created once, reset with Respawn between tests.
54306
+ /// </summary>
54307
+ [CollectionDefinition(Name)]
54308
+ public class DatabaseCollection : ICollectionFixture<DatabaseFixture>
54309
+ {
54310
+ public const string Name = nameof(DatabaseCollection);
54311
+ }
54312
+ `;
54313
+ testTenantServiceTemplate = `using {{applicationNamespace}}.Common.Interfaces.Tenants;
54314
+ using {{domainNamespace}}.Platform.Administration.Tenants;
54315
+
54316
+ namespace {{testNamespace}}.Integration;
54317
+
54318
+ /// <summary>
54319
+ /// Test implementation of ICurrentTenantService.
54320
+ /// Allows switching tenant context in tests to verify multi-tenant isolation.
54321
+ /// </summary>
54322
+ public class TestTenantService : ICurrentTenantService
54323
+ {
54324
+ private Tenant? _tenant;
54325
+ private bool _hasGlobalAccess;
54326
+
54327
+ public Guid? TenantId => _tenant?.Id;
54328
+ public string? TenantSlug => _tenant?.Slug;
54329
+ public Tenant? Tenant => _tenant;
54330
+ public bool HasTenant => _tenant is not null;
54331
+ public bool HasGlobalAccess => _hasGlobalAccess;
54332
+ public bool IsGlobalScope => !HasTenant && HasGlobalAccess;
54175
54333
 
54176
- var seeder = new TestDataSeeder(db);
54177
- await seeder.SeedAsync();
54334
+ public void SetTenant(Tenant? tenant) => _tenant = tenant;
54335
+
54336
+ public void SetGlobalAccess(bool hasGlobalAccess) => _hasGlobalAccess = hasGlobalAccess;
54337
+
54338
+ public void Clear()
54339
+ {
54340
+ _tenant = null;
54341
+ _hasGlobalAccess = false;
54178
54342
  }
54343
+
54344
+ public Task<bool> SetBySlugAsync(string slug, CancellationToken cancellationToken = default)
54345
+ => throw new NotImplementedException("Use SetTenant() directly in integration tests");
54346
+
54347
+ public Task<bool> SetByIdAsync(Guid tenantId, CancellationToken cancellationToken = default)
54348
+ => throw new NotImplementedException("Use SetTenant() directly in integration tests");
54179
54349
  }
54180
54350
  `;
54181
54351
  testAuthHandlerTemplate = `using System.Security.Claims;
@@ -54245,6 +54415,7 @@ using System.Net.Http.Json;
54245
54415
  using System.Threading.Tasks;
54246
54416
  using FluentAssertions;
54247
54417
  using Microsoft.Extensions.DependencyInjection;
54418
+ using {{applicationNamespace}}.Common.Interfaces.Tenants;
54248
54419
  using {{infrastructureNamespace}}.Persistence;
54249
54420
  using Xunit;
54250
54421
 
@@ -54252,28 +54423,36 @@ namespace {{testNamespace}}.Integration;
54252
54423
 
54253
54424
  /// <summary>
54254
54425
  /// Base class for integration tests.
54255
- /// Provides configured HttpClient with auth and tenant headers.
54426
+ /// Provides DatabaseFixture access, HttpClient with auth, and tenant switching.
54427
+ /// Uses REAL SQL Server LocalDB \u2014 not SQLite.
54256
54428
  /// </summary>
54257
- public abstract class IntegrationTestBase : IClassFixture<SmartStackTestFactory>, IAsyncLifetime
54429
+ [Collection(DatabaseCollection.Name)]
54430
+ public abstract class IntegrationTestBase : IAsyncLifetime
54258
54431
  {
54259
- protected readonly SmartStackTestFactory Factory;
54260
- protected readonly HttpClient Client;
54261
- protected readonly Guid TestTenantId = TestAuthHandler.DefaultTenantId;
54432
+ protected DatabaseFixture Database { get; }
54433
+ protected SmartStackTestFactory Factory { get; }
54434
+ protected HttpClient Client { get; }
54435
+ protected static readonly Guid TestTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111");
54262
54436
 
54263
- protected IntegrationTestBase(SmartStackTestFactory factory)
54437
+ protected IntegrationTestBase(DatabaseFixture database)
54264
54438
  {
54265
- Factory = factory;
54266
- Client = factory.CreateClient();
54439
+ Database = database;
54440
+ Factory = new SmartStackTestFactory(database.ConnectionString);
54441
+ Client = Factory.CreateClient();
54267
54442
  Client.DefaultRequestHeaders.Add("X-Tenant-Id", TestTenantId.ToString());
54268
54443
  }
54269
54444
 
54270
- public virtual async Task InitializeAsync()
54445
+ public virtual async ValueTask InitializeAsync()
54271
54446
  {
54272
- // Reset database for each test class to ensure isolation
54273
- await Factory.ResetDatabaseAsync();
54447
+ await Database.ResetDatabaseAsync();
54274
54448
  }
54275
54449
 
54276
- public virtual Task DisposeAsync() => Task.CompletedTask;
54450
+ public virtual ValueTask DisposeAsync()
54451
+ {
54452
+ Client.Dispose();
54453
+ Factory.Dispose();
54454
+ return ValueTask.CompletedTask;
54455
+ }
54277
54456
 
54278
54457
  /// <summary>
54279
54458
  /// Creates an HttpClient without authentication (for 401 tests).
@@ -54298,47 +54477,61 @@ public abstract class IntegrationTestBase : IClassFixture<SmartStackTestFactory>
54298
54477
 
54299
54478
  /// <summary>
54300
54479
  /// Gets direct access to the DbContext for verification queries.
54480
+ /// Uses global scope (no tenant filter) for cross-tenant verification.
54481
+ /// </summary>
54482
+ protected async Task<T> WithDbContextAsync<T>(Func<CoreDbContext, Task<T>> action)
54483
+ {
54484
+ await using var context = Database.CreateDbContext();
54485
+ return await action(context);
54486
+ }
54487
+
54488
+ /// <summary>
54489
+ /// Gets direct access to the DbContext with a specific tenant filter.
54301
54490
  /// </summary>
54302
- protected async Task<T> WithDbContextAsync<T>(Func<ApplicationDbContext, Task<T>> action)
54491
+ protected async Task<T> WithTenantDbContextAsync<T>(Guid tenantId, Func<CoreDbContext, Task<T>> action)
54303
54492
  {
54304
- using var scope = Factory.Services.CreateScope();
54305
- var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
54306
- return await action(db);
54493
+ var tenantService = new TestTenantService();
54494
+ tenantService.SetTenant({{domainNamespace}}.Platform.Administration.Tenants.Tenant.CreateB2C("Test", "test"));
54495
+ // Override the tenant ID
54496
+ await using var context = Database.CreateDbContext(tenantService);
54497
+ return await action(context);
54307
54498
  }
54308
54499
  }
54309
54500
  `;
54310
54501
  testDataSeederTemplate = `using System;
54311
54502
  using System.Threading.Tasks;
54503
+ using Microsoft.EntityFrameworkCore;
54312
54504
  using {{infrastructureNamespace}}.Persistence;
54505
+ using {{infrastructureNamespace}}.Persistence.Seeding;
54313
54506
 
54314
54507
  namespace {{testNamespace}}.Integration;
54315
54508
 
54316
54509
  /// <summary>
54317
- /// Seeds minimal test data required for integration tests.
54318
- /// Creates a test tenant, admin user, and base navigation/permissions.
54510
+ /// Seeds test data required for integration tests.
54511
+ /// Creates test tenant, admin user, base navigation, and permissions.
54512
+ /// Runs AFTER migrations are applied by DatabaseFixture.
54319
54513
  /// </summary>
54320
54514
  public class TestDataSeeder
54321
54515
  {
54322
- private readonly ApplicationDbContext _db;
54516
+ private readonly CoreDbContext _db;
54323
54517
 
54324
54518
  public static readonly Guid TestTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111");
54325
54519
  public static readonly Guid TestUserId = Guid.Parse("22222222-2222-2222-2222-222222222222");
54326
54520
 
54327
- public TestDataSeeder(ApplicationDbContext db)
54521
+ public TestDataSeeder(CoreDbContext db)
54328
54522
  {
54329
54523
  _db = db;
54330
54524
  }
54331
54525
 
54332
54526
  public async Task SeedAsync()
54333
54527
  {
54334
- // NOTE: Add your tenant and user seeding logic here.
54335
- // This depends on your specific entity model.
54336
- // Example:
54337
- // var tenant = Tenant.Create("test-tenant", "Test Tenant");
54338
- // _db.Set<Tenant>().Add(tenant);
54339
- // await _db.SaveChangesAsync();
54528
+ // Run core seeder (navigation, permissions, roles)
54529
+ var coreSeeder = new DatabaseSeeder(_db);
54530
+ await coreSeeder.SeedAsync();
54340
54531
 
54341
- await Task.CompletedTask;
54532
+ // Run dev data seeder (test users, tenants, demo data)
54533
+ var devSeeder = new DevDataSeeder(_db);
54534
+ await devSeeder.SeedAsync();
54342
54535
  }
54343
54536
  }
54344
54537
  `;
@@ -54356,8 +54549,8 @@ namespace {{testNamespace}}.Integration.Controllers;
54356
54549
 
54357
54550
  /// <summary>
54358
54551
  /// REAL integration tests for {{name}}Controller.
54359
- /// Uses actual DI pipeline, real services, SQLite database.
54360
- /// No mocks - verifies the complete request/response cycle.
54552
+ /// Uses actual DI pipeline, real services, SQL Server LocalDB database.
54553
+ /// No mocks \u2014 verifies the complete request/response cycle against REAL SQL Server.
54361
54554
  /// </summary>
54362
54555
  public class {{name}}ControllerIntegrationTests : IntegrationTestBase
54363
54556
  {
@@ -56338,37 +56531,75 @@ async function scaffoldRoutes(input, config2) {
56338
56531
  }
56339
56532
  result.files.push({ path: registryFile, content: registryContent, type: "created" });
56340
56533
  }
56341
- const routerContent = generateRouterConfig(navRoutes, includeGuards);
56342
- const routerFile = path19.join(routesPath, "index.tsx");
56343
- if (!dryRun) {
56344
- await ensureDirectory(routesPath);
56345
- await writeText(routerFile, routerContent);
56346
- }
56347
- result.files.push({ path: routerFile, content: routerContent, type: "created" });
56348
- if (includeLayouts) {
56349
- const layoutsPath = path19.join(webPath, "src", "layouts");
56350
- const contexts = [...new Set(navRoutes.map((r) => r.navRoute.split(".")[0]))];
56351
- for (const context of contexts) {
56352
- const layoutContent = generateLayout(context);
56353
- const layoutFile = path19.join(layoutsPath, `${capitalize(context)}Layout.tsx`);
56354
- if (!dryRun) {
56355
- await ensureDirectory(layoutsPath);
56356
- await writeText(layoutFile, layoutContent);
56357
- }
56358
- result.files.push({ path: layoutFile, content: layoutContent, type: "created" });
56534
+ const outputFormat = options?.outputFormat ?? "standalone";
56535
+ if (outputFormat === "clientRoutes") {
56536
+ const pageFiles = await discoverPageFiles(webPath, navRoutes);
56537
+ const clientRoutesContent = generateClientRoutesConfig(navRoutes, pageFiles, includeGuards);
56538
+ const clientRoutesFile = path19.join(routesPath, "clientRoutes.generated.tsx");
56539
+ if (!dryRun) {
56540
+ await ensureDirectory(routesPath);
56541
+ await writeText(clientRoutesFile, clientRoutesContent);
56359
56542
  }
56360
- }
56361
- if (includeGuards) {
56362
- const guardsContent = generateRouteGuards();
56363
- const guardsFile = path19.join(routesPath, "guards.tsx");
56543
+ result.files.push({ path: clientRoutesFile, content: clientRoutesContent, type: "created" });
56544
+ result.instructions.push(`Generated ${navRoutes.length} routes in clientRoutes format`);
56545
+ result.instructions.push("");
56546
+ result.instructions.push("## App.tsx Wiring Instructions");
56547
+ result.instructions.push("");
56548
+ result.instructions.push("Import page components and add routes INSIDE the appropriate Layout wrapper in App.tsx.");
56549
+ result.instructions.push("Routes must be added in BOTH the standard block and the tenant-prefixed block (/t/:slug/...).");
56550
+ result.instructions.push("");
56551
+ const routeTree = buildRouteTree(navRoutes);
56552
+ for (const [context, applications] of Object.entries(routeTree)) {
56553
+ const layoutName = getLayoutName(context);
56554
+ result.instructions.push(`### ${capitalize(context)} context (inside <Route path="/${context}" element={<${layoutName} />}>):`);
56555
+ result.instructions.push("");
56556
+ result.instructions.push("```tsx");
56557
+ for (const [, modules] of Object.entries(applications)) {
56558
+ for (const route of modules) {
56559
+ const modulePath = route.navRoute.split(".").slice(1).join("/");
56560
+ const pageEntry = pageFiles.get(route.navRoute);
56561
+ const component = pageEntry?.[0]?.componentName || `${route.navRoute.split(".").map(capitalize).join("")}Page`;
56562
+ result.instructions.push(`<Route path="${modulePath}" element={<${component} />} />`);
56563
+ }
56564
+ }
56565
+ result.instructions.push("```");
56566
+ result.instructions.push("");
56567
+ result.instructions.push("**IMPORTANT:** Also add the same routes inside the tenant-prefixed block (`/t/:slug/...`)");
56568
+ result.instructions.push("");
56569
+ }
56570
+ } else {
56571
+ const routerContent = generateRouterConfig(navRoutes, includeGuards);
56572
+ const routerFile = path19.join(routesPath, "index.tsx");
56364
56573
  if (!dryRun) {
56365
- await writeText(guardsFile, guardsContent);
56574
+ await ensureDirectory(routesPath);
56575
+ await writeText(routerFile, routerContent);
56576
+ }
56577
+ result.files.push({ path: routerFile, content: routerContent, type: "created" });
56578
+ if (includeLayouts) {
56579
+ const layoutsPath = path19.join(webPath, "src", "layouts");
56580
+ const contexts = [...new Set(navRoutes.map((r) => r.navRoute.split(".")[0]))];
56581
+ for (const context of contexts) {
56582
+ const layoutContent = generateLayout(context);
56583
+ const layoutFile = path19.join(layoutsPath, `${capitalize(context)}Layout.tsx`);
56584
+ if (!dryRun) {
56585
+ await ensureDirectory(layoutsPath);
56586
+ await writeText(layoutFile, layoutContent);
56587
+ }
56588
+ result.files.push({ path: layoutFile, content: layoutContent, type: "created" });
56589
+ }
56590
+ }
56591
+ if (includeGuards) {
56592
+ const guardsContent = generateRouteGuards();
56593
+ const guardsFile = path19.join(routesPath, "guards.tsx");
56594
+ if (!dryRun) {
56595
+ await writeText(guardsFile, guardsContent);
56596
+ }
56597
+ result.files.push({ path: guardsFile, content: guardsContent, type: "created" });
56366
56598
  }
56367
- result.files.push({ path: guardsFile, content: guardsContent, type: "created" });
56599
+ result.instructions.push(`Generated ${navRoutes.length} routes from ${source}`);
56600
+ result.instructions.push('Import routes: import { router } from "./routes";');
56601
+ result.instructions.push("Use with RouterProvider: <RouterProvider router={router} />");
56368
56602
  }
56369
- result.instructions.push(`Generated ${navRoutes.length} routes from ${source}`);
56370
- result.instructions.push('Import routes: import { router } from "./routes";');
56371
- result.instructions.push("Use with RouterProvider: <RouterProvider router={router} />");
56372
56603
  return result;
56373
56604
  }
56374
56605
  async function discoverNavRoutes(structure, scope) {
@@ -56764,6 +56995,157 @@ export const PermissionGuard: React.FC<PermissionGuardProps> = ({
56764
56995
  };
56765
56996
  `;
56766
56997
  }
56998
+ async function discoverPageFiles(webPath, routes) {
56999
+ const pageMap = /* @__PURE__ */ new Map();
57000
+ for (const route of routes) {
57001
+ const parts = route.navRoute.split(".");
57002
+ const context = capitalize(parts[0]);
57003
+ const app = capitalize(parts[1] || "default");
57004
+ const moduleParts = parts.slice(2).map(capitalize);
57005
+ const moduleDir = moduleParts.join("/");
57006
+ const pagesDir = path19.join(webPath, "src", "pages", context, app, moduleDir || "");
57007
+ try {
57008
+ const pageFiles = await glob("*Page.tsx", { cwd: pagesDir, absolute: false });
57009
+ if (pageFiles.length > 0) {
57010
+ const entries = pageFiles.map((f) => {
57011
+ const componentName = f.replace(".tsx", "");
57012
+ const importPath = `@/pages/${context}/${app}/${moduleDir ? moduleDir + "/" : ""}${componentName}`;
57013
+ return { importPath, componentName };
57014
+ });
57015
+ pageMap.set(route.navRoute, entries);
57016
+ }
57017
+ } catch {
57018
+ logger.debug(`No pages found for route ${route.navRoute} at ${pagesDir}`);
57019
+ }
57020
+ }
57021
+ return pageMap;
57022
+ }
57023
+ function generateClientRoutesConfig(routes, pageFiles, includeGuards) {
57024
+ const routeTree = buildRouteTree(routes);
57025
+ const lines = [
57026
+ "/**",
57027
+ " * Client Routes Configuration",
57028
+ " *",
57029
+ " * Auto-generated by SmartStack MCP - DO NOT EDIT MANUALLY",
57030
+ ' * Run `scaffold_routes` with outputFormat: "clientRoutes" to regenerate.',
57031
+ " *",
57032
+ " * These routes must be added INSIDE the appropriate Layout wrapper in App.tsx.",
57033
+ " * They are NOT standalone - they are children of the context layout routes.",
57034
+ " * Routes must be added in BOTH the standard block and the tenant-prefixed block.",
57035
+ " */",
57036
+ "",
57037
+ "import type { RouteObject } from 'react-router-dom';",
57038
+ "import { Navigate } from 'react-router-dom';"
57039
+ ];
57040
+ if (includeGuards) {
57041
+ lines.push("import { ROUTES } from './navRoutes.generated';");
57042
+ lines.push("import { PermissionGuard } from './guards';");
57043
+ }
57044
+ lines.push("");
57045
+ const importedComponents = /* @__PURE__ */ new Set();
57046
+ for (const route of routes) {
57047
+ const pageEntries = pageFiles.get(route.navRoute);
57048
+ if (pageEntries) {
57049
+ for (const entry of pageEntries) {
57050
+ if (!importedComponents.has(entry.componentName)) {
57051
+ lines.push(`import { ${entry.componentName} } from '${entry.importPath}';`);
57052
+ importedComponents.add(entry.componentName);
57053
+ }
57054
+ }
57055
+ }
57056
+ }
57057
+ for (const route of routes) {
57058
+ if (!pageFiles.has(route.navRoute)) {
57059
+ const pageName = route.navRoute.split(".").map(capitalize).join("") + "Page";
57060
+ lines.push(`// TODO: import { ${pageName} } from '@/pages/...';`);
57061
+ }
57062
+ }
57063
+ lines.push("");
57064
+ for (const [context, applications] of Object.entries(routeTree)) {
57065
+ const contextUpper = capitalize(context);
57066
+ const layoutName = getLayoutName(context);
57067
+ lines.push("/**");
57068
+ lines.push(` * Routes for ${contextUpper} context`);
57069
+ lines.push(` * Add these as children of <Route path="/${context}" element={<${layoutName} />}>`);
57070
+ lines.push(" */");
57071
+ lines.push(`export const ${context}Routes: RouteObject[] = [`);
57072
+ for (const [app, modules] of Object.entries(applications)) {
57073
+ if (modules.length > 1) {
57074
+ lines.push(" {");
57075
+ lines.push(` path: '${app}',`);
57076
+ lines.push(" children: [");
57077
+ const firstModulePath = modules[0].navRoute.split(".").slice(2).join("/");
57078
+ lines.push(` { index: true, element: <Navigate to="${firstModulePath}" replace /> },`);
57079
+ for (const route of modules) {
57080
+ const modulePath = route.navRoute.split(".").slice(2).join("/");
57081
+ const pageName = route.navRoute.split(".").map(capitalize).join("") + "Page";
57082
+ const pageEntry = pageFiles.get(route.navRoute);
57083
+ const component = pageEntry?.[0]?.componentName || pageName;
57084
+ const hasRealPage = pageFiles.has(route.navRoute);
57085
+ if (includeGuards && route.permissions.length > 0) {
57086
+ lines.push(` {`);
57087
+ lines.push(` path: '${modulePath}',`);
57088
+ if (hasRealPage) {
57089
+ lines.push(` element: <PermissionGuard permissions={ROUTES['${route.navRoute}'].permissions}><${component} /></PermissionGuard>,`);
57090
+ } else {
57091
+ lines.push(` element: <PermissionGuard permissions={ROUTES['${route.navRoute}'].permissions}><div>TODO: ${component}</div></PermissionGuard>,`);
57092
+ }
57093
+ lines.push(` },`);
57094
+ } else {
57095
+ lines.push(` {`);
57096
+ lines.push(` path: '${modulePath}',`);
57097
+ lines.push(` element: ${hasRealPage ? `<${component} />` : `<div>TODO: ${component}</div>`},`);
57098
+ lines.push(` },`);
57099
+ }
57100
+ }
57101
+ lines.push(" ],");
57102
+ lines.push(" },");
57103
+ } else {
57104
+ for (const route of modules) {
57105
+ const fullPath = route.navRoute.split(".").slice(1).join("/");
57106
+ const pageName = route.navRoute.split(".").map(capitalize).join("") + "Page";
57107
+ const pageEntry = pageFiles.get(route.navRoute);
57108
+ const component = pageEntry?.[0]?.componentName || pageName;
57109
+ const hasRealPage = pageFiles.has(route.navRoute);
57110
+ if (includeGuards && route.permissions.length > 0) {
57111
+ lines.push(` {`);
57112
+ lines.push(` path: '${fullPath}',`);
57113
+ if (hasRealPage) {
57114
+ lines.push(` element: <PermissionGuard permissions={ROUTES['${route.navRoute}'].permissions}><${component} /></PermissionGuard>,`);
57115
+ } else {
57116
+ lines.push(` element: <PermissionGuard permissions={ROUTES['${route.navRoute}'].permissions}><div>TODO: ${component}</div></PermissionGuard>,`);
57117
+ }
57118
+ lines.push(` },`);
57119
+ } else {
57120
+ lines.push(` {`);
57121
+ lines.push(` path: '${fullPath}',`);
57122
+ lines.push(` element: ${hasRealPage ? `<${component} />` : `<div>TODO: ${component}</div>`},`);
57123
+ lines.push(` },`);
57124
+ }
57125
+ }
57126
+ }
57127
+ }
57128
+ lines.push("];");
57129
+ lines.push("");
57130
+ }
57131
+ const contexts = Object.keys(routeTree);
57132
+ lines.push("/** All generated routes grouped by context */");
57133
+ lines.push("export const generatedRoutes = {");
57134
+ for (const context of contexts) {
57135
+ lines.push(` ${context}: ${context}Routes,`);
57136
+ }
57137
+ lines.push("};");
57138
+ lines.push("");
57139
+ return lines.join("\n");
57140
+ }
57141
+ function getLayoutName(context) {
57142
+ const layoutMap = {
57143
+ platform: "AdminLayout",
57144
+ business: "BusinessLayout",
57145
+ personal: "UserLayout"
57146
+ };
57147
+ return layoutMap[context] || `${capitalize(context)}Layout`;
57148
+ }
56767
57149
  function capitalize(str) {
56768
57150
  return str.charAt(0).toUpperCase() + str.slice(1);
56769
57151
  }
@@ -56848,6 +57230,7 @@ and generates corresponding frontend routing infrastructure.`,
56848
57230
  includeLayouts: { type: "boolean", default: true },
56849
57231
  includeGuards: { type: "boolean", default: true },
56850
57232
  generateRegistry: { type: "boolean", default: true },
57233
+ outputFormat: { type: "string", enum: ["standalone", "clientRoutes"], default: "standalone", description: "standalone: createBrowserRouter(), clientRoutes: RouteObject[] arrays for App.tsx" },
56851
57234
  dryRun: { type: "boolean", default: false }
56852
57235
  }
56853
57236
  }
@@ -56883,6 +57266,12 @@ async function validateFrontendRoutes(input, config2) {
56883
57266
  orphaned: [],
56884
57267
  missing: []
56885
57268
  },
57269
+ appWiring: {
57270
+ exists: false,
57271
+ routesImported: false,
57272
+ routeFileChecked: "",
57273
+ issues: []
57274
+ },
56886
57275
  recommendations: []
56887
57276
  };
56888
57277
  const { scope } = input;
@@ -56899,8 +57288,11 @@ async function validateFrontendRoutes(input, config2) {
56899
57288
  if (scope === "all" || scope === "routes") {
56900
57289
  await validateRoutes(webPath, backendRoutes, result);
56901
57290
  }
57291
+ if (scope === "all" || scope === "routes") {
57292
+ await validateAppWiring(webPath, backendRoutes, result);
57293
+ }
56902
57294
  generateRecommendations2(result);
56903
- result.valid = result.apiClients.issues.filter((i) => i.severity === "error").length === 0 && result.routes.missing.length === 0 && result.registry.exists;
57295
+ result.valid = result.apiClients.issues.filter((i) => i.severity === "error").length === 0 && result.routes.missing.length === 0 && result.registry.exists && result.appWiring.issues.length === 0;
56904
57296
  return result;
56905
57297
  }
56906
57298
  async function discoverBackendNavRoutes(structure) {
@@ -57072,6 +57464,51 @@ async function validateRoutes(webPath, backendRoutes, result) {
57072
57464
  result.routes.missing = backendRoutes.map((r) => r.navRoute);
57073
57465
  }
57074
57466
  }
57467
+ async function validateAppWiring(webPath, backendRoutes, result) {
57468
+ const appCandidates = ["App.tsx", "main.tsx"];
57469
+ let appFilePath = null;
57470
+ let appContent = "";
57471
+ for (const candidate of appCandidates) {
57472
+ const candidatePath = path20.join(webPath, "src", candidate);
57473
+ if (await fileExists(candidatePath)) {
57474
+ appFilePath = candidatePath;
57475
+ try {
57476
+ appContent = await readText(candidatePath);
57477
+ } catch {
57478
+ logger.debug(`Failed to read ${candidatePath}`);
57479
+ }
57480
+ break;
57481
+ }
57482
+ }
57483
+ result.appWiring = {
57484
+ exists: !!appFilePath,
57485
+ routesImported: false,
57486
+ routeFileChecked: appFilePath || "",
57487
+ issues: []
57488
+ };
57489
+ if (!appFilePath || !appContent) {
57490
+ result.appWiring.issues.push("No App.tsx or main.tsx found in web/src/");
57491
+ return;
57492
+ }
57493
+ const hasClientRoutesImport = appContent.includes("clientRoutes.generated");
57494
+ const hasRoutesImport = appContent.includes("from './routes") || appContent.includes("from '../routes");
57495
+ const hasInlineRoutes = appContent.includes("<Route ");
57496
+ result.appWiring.routesImported = hasClientRoutesImport || hasRoutesImport || hasInlineRoutes;
57497
+ if (!result.appWiring.routesImported) {
57498
+ result.appWiring.issues.push("App.tsx does not import any route configuration");
57499
+ return;
57500
+ }
57501
+ for (const route of backendRoutes) {
57502
+ const modulePath = route.navRoute.split(".").slice(1).join("/");
57503
+ const lastSegment = route.navRoute.split(".").pop() || "";
57504
+ const pathInApp = appContent.includes(`path="${modulePath}"`) || appContent.includes(`path='${modulePath}'`) || appContent.includes(`path="${lastSegment}"`) || appContent.includes(`path='${lastSegment}'`);
57505
+ if (!pathInApp) {
57506
+ result.appWiring.issues.push(
57507
+ `Route "${route.navRoute}" (path: ${modulePath}) not wired in App.tsx`
57508
+ );
57509
+ }
57510
+ }
57511
+ }
57075
57512
  function generateRecommendations2(result) {
57076
57513
  if (!result.registry.exists) {
57077
57514
  result.recommendations.push('Run `scaffold_routes source="controllers"` to generate route registry');
@@ -57094,6 +57531,14 @@ function generateRecommendations2(result) {
57094
57531
  if (result.routes.orphaned.length > 0) {
57095
57532
  result.recommendations.push(`${result.routes.orphaned.length} frontend routes have no backend NavRoute`);
57096
57533
  }
57534
+ if (result.appWiring && !result.appWiring.exists) {
57535
+ result.recommendations.push("No App.tsx found. Create App.tsx with route configuration.");
57536
+ } else if (result.appWiring && result.appWiring.issues.length > 0) {
57537
+ const unwired = result.appWiring.issues.filter((i) => i.includes("not wired")).length;
57538
+ if (unwired > 0) {
57539
+ result.recommendations.push(`${unwired} backend routes not wired in App.tsx. Add <Route> entries inside Layout wrappers.`);
57540
+ }
57541
+ }
57097
57542
  if (result.valid && result.recommendations.length === 0) {
57098
57543
  result.recommendations.push("All routes are synchronized between frontend and backend");
57099
57544
  }
@@ -57159,6 +57604,26 @@ function formatResult6(result, _input) {
57159
57604
  }
57160
57605
  lines.push("");
57161
57606
  }
57607
+ if (result.appWiring) {
57608
+ lines.push("## App.tsx Wiring");
57609
+ lines.push("");
57610
+ lines.push("| Check | Status |");
57611
+ lines.push("|-------|--------|");
57612
+ lines.push(`| App file exists | ${result.appWiring.exists ? "Yes" : "No"} |`);
57613
+ lines.push(`| Routes imported | ${result.appWiring.routesImported ? "Yes" : "No"} |`);
57614
+ if (result.appWiring.routeFileChecked) {
57615
+ lines.push(`| File checked | ${path20.basename(result.appWiring.routeFileChecked)} |`);
57616
+ }
57617
+ lines.push("");
57618
+ if (result.appWiring.issues.length > 0) {
57619
+ lines.push("### Wiring Issues");
57620
+ lines.push("");
57621
+ for (const issue2 of result.appWiring.issues) {
57622
+ lines.push(`- ${issue2}`);
57623
+ }
57624
+ lines.push("");
57625
+ }
57626
+ }
57162
57627
  lines.push("## Recommendations");
57163
57628
  lines.push("");
57164
57629
  for (const rec of result.recommendations) {
@@ -63063,6 +63528,26 @@ Tables are organized by domain using prefixes:
63063
63528
  | \`lic_\` | Licensing | lic_Licenses |
63064
63529
  | \`tenant_\` | Multi-Tenancy | tenant_Tenants, tenant_TenantUsers, tenant_TenantUserRoles |
63065
63530
 
63531
+ ### Custom Application Prefixes
63532
+
63533
+ In addition to the platform prefixes above, client applications define their own table prefix during business analysis (\`/business-analyse\` cadrage phase). This prefix identifies which business application a table belongs to.
63534
+
63535
+ **Format:** \`{2-5 lowercase letters}_\` (e.g., \`rh_\`, \`fi_\`, \`crm_\`, \`sales_\`)
63536
+
63537
+ **Examples:**
63538
+
63539
+ | Application | Prefix | Example Tables |
63540
+ |-------------|--------|----------------|
63541
+ | Human Resources | \`rh_\` | rh_Employees, rh_Contracts, rh_LeaveRequests |
63542
+ | Finance | \`fi_\` | fi_Invoices, fi_Payments, fi_BudgetLines |
63543
+ | CRM | \`crm_\` | crm_Contacts, crm_Opportunities, crm_Campaigns |
63544
+
63545
+ **Rules:**
63546
+ 1. Defined once per application in \`feature.json\` (\`metadata.tablePrefix\`)
63547
+ 2. All entities in the application use the same prefix
63548
+ 3. Must not collide with platform prefixes (auth_, nav_, etc.)
63549
+ 4. Registered in MCP config via \`conventions.customTablePrefixes\` for validation
63550
+
63066
63551
  ### Navigation Scope System
63067
63552
 
63068
63553
  All navigation data (Context, Application, Module, Section) uses a **Scope** field to distinguish system data from client extensions: