@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.
- package/dist/index.js +26 -28
- package/dist/index.js.map +1 -1
- package/dist/mcp-entry.mjs +626 -141
- package/dist/mcp-entry.mjs.map +1 -1
- package/package.json +1 -1
- package/templates/agents/efcore/migration.md +15 -0
- package/templates/skills/apex/steps/step-04-validate.md +64 -5
- package/templates/skills/application/references/frontend-verification.md +20 -0
- package/templates/skills/application/steps/step-04-backend.md +17 -1
- package/templates/skills/application/steps/step-05-frontend.md +49 -23
- package/templates/skills/application/templates-seed.md +14 -4
- package/templates/skills/business-analyse/SKILL.md +3 -2
- package/templates/skills/business-analyse/_module-loop.md +5 -5
- package/templates/skills/business-analyse/html/ba-interactive.html +165 -0
- package/templates/skills/business-analyse/html/src/scripts/01-data-init.js +2 -0
- package/templates/skills/business-analyse/html/src/scripts/06-render-consolidation.js +85 -0
- package/templates/skills/business-analyse/html/src/styles/05-modules.css +65 -0
- package/templates/skills/business-analyse/html/src/template.html +13 -0
- package/templates/skills/business-analyse/questionnaire.md +1 -1
- package/templates/skills/business-analyse/references/cache-warming-strategy.md +11 -23
- package/templates/skills/business-analyse/references/cadrage-pre-analysis.md +112 -0
- package/templates/skills/business-analyse/references/cadrage-structure-cards.md +6 -1
- package/templates/skills/business-analyse/references/deploy-data-build.md +1 -1
- package/templates/skills/business-analyse/references/html-data-mapping.md +1 -1
- package/templates/skills/business-analyse/references/robustness-checks.md +1 -1
- package/templates/skills/business-analyse/references/spec-auto-inference.md +1 -1
- package/templates/skills/business-analyse/schemas/application-schema.json +38 -1
- package/templates/skills/business-analyse/schemas/sections/metadata-schema.json +2 -1
- package/templates/skills/business-analyse/steps/step-00-init.md +18 -22
- package/templates/skills/business-analyse/steps/step-01-cadrage.md +383 -128
- package/templates/skills/business-analyse/steps/step-02-decomposition.md +42 -16
- package/templates/skills/business-analyse/steps/step-03a-data.md +5 -31
- package/templates/skills/business-analyse/steps/step-03a1-setup.md +41 -2
- package/templates/skills/business-analyse/steps/step-03b-ui.md +20 -11
- package/templates/skills/business-analyse/steps/step-03d-validate.md +6 -6
- package/templates/skills/business-analyse/steps/step-04-consolidation.md +5 -31
- package/templates/skills/business-analyse/steps/step-04c-decide.md +1 -1
- package/templates/skills/business-analyse/steps/step-05a-handoff.md +1 -1
- package/templates/skills/business-analyse/steps/step-05b-deploy.md +3 -3
- package/templates/skills/business-analyse/steps/step-05c-ralph-readiness.md +1 -1
- package/templates/skills/business-analyse/templates/tpl-frd.md +1 -1
- package/templates/skills/efcore/steps/shared/step-00-init.md +55 -0
- package/templates/skills/ralph-loop/SKILL.md +1 -0
- package/templates/skills/ralph-loop/references/category-rules.md +131 -27
- package/templates/skills/ralph-loop/references/compact-loop.md +61 -3
- package/templates/skills/ralph-loop/references/core-seed-data.md +251 -5
- package/templates/skills/ralph-loop/references/error-classification.md +143 -0
- package/templates/skills/ralph-loop/steps/step-05-report.md +54 -0
- package/templates/skills/review-code/references/smartstack-conventions.md +16 -0
- package/templates/skills/validate-feature/SKILL.md +11 -1
- package/templates/skills/validate-feature/steps/step-00-dependencies.md +121 -0
- package/templates/skills/validate-feature/steps/step-04-api-smoke.md +61 -13
- package/templates/skills/validate-feature/steps/step-05-db-validation.md +250 -0
- package/templates/skills/business-analyse/references/cadrage-vibe-coding.md +0 -87
package/dist/mcp-entry.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
52667
|
-
result.instructions.push(`Add package reference: <PackageReference Include="Microsoft.Data.
|
|
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
|
-
///
|
|
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
|
-
|
|
53702
|
+
[Collection(DatabaseCollection.Name)]
|
|
53703
|
+
public class {{name}}RepositoryTests : IAsyncLifetime
|
|
53690
53704
|
{
|
|
53691
|
-
private readonly
|
|
53692
|
-
private
|
|
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.
|
|
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
|
-
|
|
53700
|
-
|
|
53701
|
-
.Options;
|
|
53714
|
+
_database = database;
|
|
53715
|
+
}
|
|
53702
53716
|
|
|
53703
|
-
|
|
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
|
|
53724
|
+
public async ValueTask DisposeAsync()
|
|
53708
53725
|
{
|
|
53709
|
-
_context
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
///
|
|
54104
|
-
/// Uses SQLite
|
|
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
|
|
54126
|
+
public class SmartStackTestFactory : WebApplicationFactory<Program>
|
|
54108
54127
|
{
|
|
54109
|
-
private
|
|
54128
|
+
private readonly string _connectionString;
|
|
54110
54129
|
|
|
54111
|
-
|
|
54130
|
+
public SmartStackTestFactory(string connectionString)
|
|
54112
54131
|
{
|
|
54113
|
-
|
|
54132
|
+
_connectionString = connectionString;
|
|
54133
|
+
}
|
|
54134
|
+
|
|
54135
|
+
protected override IHost CreateHost(IHostBuilder builder)
|
|
54136
|
+
{
|
|
54137
|
+
builder.ConfigureHostConfiguration(config =>
|
|
54114
54138
|
{
|
|
54115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54138
|
-
|
|
54139
|
-
|
|
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.
|
|
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
|
|
54213
|
+
public async ValueTask InitializeAsync()
|
|
54146
54214
|
{
|
|
54147
|
-
|
|
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
|
-
|
|
54153
|
-
|
|
54154
|
-
await
|
|
54217
|
+
await CreateDatabaseAsync();
|
|
54218
|
+
await ApplyMigrationsAsync();
|
|
54219
|
+
await InitializeRespawnerAsync();
|
|
54155
54220
|
}
|
|
54156
54221
|
|
|
54157
|
-
async
|
|
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
|
|
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
|
-
|
|
54172
|
-
|
|
54173
|
-
|
|
54174
|
-
|
|
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
|
-
|
|
54177
|
-
|
|
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
|
|
54426
|
+
/// Provides DatabaseFixture access, HttpClient with auth, and tenant switching.
|
|
54427
|
+
/// Uses REAL SQL Server LocalDB \u2014 not SQLite.
|
|
54256
54428
|
/// </summary>
|
|
54257
|
-
|
|
54429
|
+
[Collection(DatabaseCollection.Name)]
|
|
54430
|
+
public abstract class IntegrationTestBase : IAsyncLifetime
|
|
54258
54431
|
{
|
|
54259
|
-
protected
|
|
54260
|
-
protected
|
|
54261
|
-
protected
|
|
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(
|
|
54437
|
+
protected IntegrationTestBase(DatabaseFixture database)
|
|
54264
54438
|
{
|
|
54265
|
-
|
|
54266
|
-
|
|
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
|
|
54445
|
+
public virtual async ValueTask InitializeAsync()
|
|
54271
54446
|
{
|
|
54272
|
-
|
|
54273
|
-
await Factory.ResetDatabaseAsync();
|
|
54447
|
+
await Database.ResetDatabaseAsync();
|
|
54274
54448
|
}
|
|
54275
54449
|
|
|
54276
|
-
public virtual
|
|
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>
|
|
54491
|
+
protected async Task<T> WithTenantDbContextAsync<T>(Guid tenantId, Func<CoreDbContext, Task<T>> action)
|
|
54303
54492
|
{
|
|
54304
|
-
|
|
54305
|
-
|
|
54306
|
-
|
|
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
|
|
54318
|
-
/// Creates
|
|
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
|
|
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(
|
|
54521
|
+
public TestDataSeeder(CoreDbContext db)
|
|
54328
54522
|
{
|
|
54329
54523
|
_db = db;
|
|
54330
54524
|
}
|
|
54331
54525
|
|
|
54332
54526
|
public async Task SeedAsync()
|
|
54333
54527
|
{
|
|
54334
|
-
//
|
|
54335
|
-
|
|
54336
|
-
|
|
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
|
-
|
|
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,
|
|
54360
|
-
/// No mocks
|
|
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
|
|
56342
|
-
|
|
56343
|
-
|
|
56344
|
-
|
|
56345
|
-
|
|
56346
|
-
|
|
56347
|
-
|
|
56348
|
-
|
|
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
|
-
|
|
56362
|
-
|
|
56363
|
-
|
|
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
|
|
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.
|
|
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:
|