@atlashub/smartstack-cli 3.46.0 → 3.47.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/mcp-entry.mjs +297 -85
- package/dist/mcp-entry.mjs.map +1 -1
- package/package.json +1 -1
- package/templates/skills/apex/SKILL.md +1 -0
- package/templates/skills/apex/references/analysis-methods.md +27 -16
- package/templates/skills/apex/references/challenge-questions.md +143 -0
- package/templates/skills/apex/references/person-extension-pattern.md +596 -0
- package/templates/skills/apex/references/post-checks.md +78 -0
- package/templates/skills/apex/references/smartstack-api.md +69 -0
- package/templates/skills/apex/references/smartstack-layers.md +13 -0
- package/templates/skills/apex/steps/step-00-init.md +27 -5
- package/templates/skills/apex/steps/step-03-execute.md +13 -0
- package/templates/skills/business-analyse/_architecture.md +1 -0
- package/templates/skills/business-analyse/references/entity-architecture-decision.md +22 -0
- package/templates/skills/business-analyse/references/handoff-mappings.md +14 -0
- package/templates/skills/business-analyse/references/spec-auto-inference.md +1 -0
- package/templates/skills/business-analyse/steps/step-03a2-analysis.md +49 -0
- package/templates/skills/gitflow/_shared.md +2 -2
- package/templates/skills/gitflow/references/init-version-detection.md +1 -1
- package/templates/skills/gitflow/steps/step-init.md +13 -8
- package/templates/skills/gitflow/templates/config.json +1 -1
- package/templates/skills/ralph-loop/references/category-rules.md +13 -0
package/dist/mcp-entry.mjs
CHANGED
|
@@ -25919,7 +25919,9 @@ var init_types3 = __esm({
|
|
|
25919
25919
|
navRoute: external_exports.string().optional().describe('Navigation route path for controller (e.g., "administration.users"). Required for controllers.'),
|
|
25920
25920
|
navRouteSuffix: external_exports.string().optional().describe('Optional suffix for NavRoute (e.g., "dashboard" for sub-resources)'),
|
|
25921
25921
|
withHierarchyFunction: external_exports.boolean().optional().describe("For entity type with self-reference (ParentId): generate TVF SQL script for hierarchy traversal"),
|
|
25922
|
-
hierarchyDirection: external_exports.enum(["ancestors", "descendants", "both"]).optional().describe("Direction for hierarchy traversal function (default: both)")
|
|
25922
|
+
hierarchyDirection: external_exports.enum(["ancestors", "descendants", "both"]).optional().describe("Direction for hierarchy traversal function (default: both)"),
|
|
25923
|
+
isPersonRole: external_exports.boolean().optional().describe("If true, entity is a Person Extension linked to User via UserId FK"),
|
|
25924
|
+
userLinkMode: external_exports.enum(["mandatory", "optional"]).optional().describe("Person Extension mode: mandatory (always a User) or optional (may or may not have a User)")
|
|
25923
25925
|
}).optional()
|
|
25924
25926
|
});
|
|
25925
25927
|
ApiDocsInputSchema = external_exports.object({
|
|
@@ -27012,6 +27014,25 @@ async function validateEntities(structure, _config, result) {
|
|
|
27012
27014
|
suggestion: `Add factory method: public static ${entityName} Create(...)`
|
|
27013
27015
|
});
|
|
27014
27016
|
}
|
|
27017
|
+
const hasUserId = content.includes("UserId") && content.includes("private set");
|
|
27018
|
+
const hasUserNavProp = /public\s+User\?\s+User\s*{/.test(content);
|
|
27019
|
+
if (hasUserId && hasUserNavProp && hasAnyTenantInterface) {
|
|
27020
|
+
const personFields = ["FirstName", "LastName", "Email", "PhoneNumber"];
|
|
27021
|
+
const duplicatedFields = personFields.filter((field) => {
|
|
27022
|
+
const propPattern = new RegExp(`public\\s+string[?]?\\s+${field}\\s*{`);
|
|
27023
|
+
return propPattern.test(content);
|
|
27024
|
+
});
|
|
27025
|
+
const hasDisplayProps = content.includes("DisplayFirstName") || content.includes("DisplayLastName");
|
|
27026
|
+
if (duplicatedFields.length >= 2 && !hasDisplayProps) {
|
|
27027
|
+
result.warnings.push({
|
|
27028
|
+
type: "warning",
|
|
27029
|
+
category: "entities",
|
|
27030
|
+
message: `Entity "${entityName}" has UserId (Person Extension) but also declares person fields (${duplicatedFields.join(", ")}) \u2014 these may duplicate User data`,
|
|
27031
|
+
file: path8.relative(structure.root, file),
|
|
27032
|
+
suggestion: "For mandatory Person Extension: remove person fields and read them from User. For optional: add Display* computed properties (DisplayFirstName => User?.FirstName ?? FirstName)"
|
|
27033
|
+
});
|
|
27034
|
+
}
|
|
27035
|
+
}
|
|
27015
27036
|
}
|
|
27016
27037
|
}
|
|
27017
27038
|
async function validateTenantAwareness(structure, _config, result) {
|
|
@@ -34771,6 +34792,10 @@ namespace {{implNamespace}};
|
|
|
34771
34792
|
/// Scoped entity: EF global query filter includes shared + current tenant data. Scope controls visibility.
|
|
34772
34793
|
{{/if}}
|
|
34773
34794
|
/// IMPORTANT: GetAllAsync MUST support search parameter for frontend EntityLookup component.
|
|
34795
|
+
{{#if isPersonRole}}
|
|
34796
|
+
/// IMPORTANT: Person Extension \u2014 always include User for person data resolution.
|
|
34797
|
+
/// All queries must .Include(x => x.User).
|
|
34798
|
+
{{/if}}
|
|
34774
34799
|
/// </summary>
|
|
34775
34800
|
public class {{name}}Service : I{{name}}Service
|
|
34776
34801
|
{
|
|
@@ -34833,7 +34858,8 @@ public class {{name}}Service : I{{name}}Service
|
|
|
34833
34858
|
const diTemplate = `// Add to DependencyInjection.cs or ServiceCollectionExtensions.cs:
|
|
34834
34859
|
services.AddScoped<I{{name}}Service, {{name}}Service>();
|
|
34835
34860
|
`;
|
|
34836
|
-
const
|
|
34861
|
+
const isPersonRole = options?.isPersonRole || false;
|
|
34862
|
+
const context = { interfaceNamespace, implNamespace, name, methods, ...tmFlags, isPersonRole };
|
|
34837
34863
|
const interfaceContent = import_handlebars.default.compile(interfaceTemplate)(context);
|
|
34838
34864
|
const implementationContent = import_handlebars.default.compile(implementationTemplate)(context);
|
|
34839
34865
|
const diContent = import_handlebars.default.compile(diTemplate)(context);
|
|
@@ -34866,6 +34892,8 @@ async function scaffoldEntity(name, options, structure, config2, result, dryRun
|
|
|
34866
34892
|
const isSystemEntity = tmFlags.isSystemEntity;
|
|
34867
34893
|
const tablePrefix = options?.tablePrefix || "ref_";
|
|
34868
34894
|
const schema = options?.schema || config2.conventions.schemas.platform;
|
|
34895
|
+
const isPersonRole = options?.isPersonRole || false;
|
|
34896
|
+
const userLinkMode = options?.userLinkMode || "mandatory";
|
|
34869
34897
|
const entityTemplate = `using System;
|
|
34870
34898
|
using SmartStack.Domain.Common;
|
|
34871
34899
|
|
|
@@ -34937,6 +34965,38 @@ public class {{name}} : BaseEntity, IAuditableEntity
|
|
|
34937
34965
|
/// </summary>
|
|
34938
34966
|
public {{baseEntity}}? {{baseEntity}} { get; private set; }
|
|
34939
34967
|
|
|
34968
|
+
{{/if}}
|
|
34969
|
+
{{#if isPersonRoleMandatory}}
|
|
34970
|
+
// === USER LINK (Person Extension \u2014 mandatory) ===
|
|
34971
|
+
|
|
34972
|
+
/// <summary>
|
|
34973
|
+
/// Link to the User entity. This person is ALWAYS a system user.
|
|
34974
|
+
/// Person data (FirstName, LastName, Email) comes from User \u2014 never duplicated here.
|
|
34975
|
+
/// </summary>
|
|
34976
|
+
public Guid UserId { get; private set; }
|
|
34977
|
+
public User? User { get; private set; }
|
|
34978
|
+
|
|
34979
|
+
{{else if isPersonRoleOptional}}
|
|
34980
|
+
// === USER LINK (Person Extension \u2014 optional) ===
|
|
34981
|
+
|
|
34982
|
+
/// <summary>
|
|
34983
|
+
/// Optional link to a User entity. This person MAY have a system account.
|
|
34984
|
+
/// When linked, person data comes from User (via Display* properties).
|
|
34985
|
+
/// </summary>
|
|
34986
|
+
public Guid? UserId { get; private set; }
|
|
34987
|
+
public User? User { get; private set; }
|
|
34988
|
+
|
|
34989
|
+
// === PERSON FIELDS (standalone when no User linked) ===
|
|
34990
|
+
public string FirstName { get; private set; } = null!;
|
|
34991
|
+
public string LastName { get; private set; } = null!;
|
|
34992
|
+
public string? Email { get; private set; }
|
|
34993
|
+
public string? PhoneNumber { get; private set; }
|
|
34994
|
+
|
|
34995
|
+
// === COMPUTED (read from User if linked, else from own fields) ===
|
|
34996
|
+
public string DisplayFirstName => User?.FirstName ?? FirstName;
|
|
34997
|
+
public string DisplayLastName => User?.LastName ?? LastName;
|
|
34998
|
+
public string? DisplayEmail => User?.Email ?? Email;
|
|
34999
|
+
|
|
34940
35000
|
{{/if}}
|
|
34941
35001
|
// === BUSINESS PROPERTIES ===
|
|
34942
35002
|
// TODO: Add {{name}} specific properties here
|
|
@@ -35073,6 +35133,35 @@ public class {{name}}Configuration : IEntityTypeConfiguration<{{name}}>
|
|
|
35073
35133
|
.HasDatabaseName("IX_{{tablePrefix}}{{name}}s_{{baseEntity}}Id");
|
|
35074
35134
|
{{/if}}
|
|
35075
35135
|
|
|
35136
|
+
{{#if isPersonRoleMandatory}}
|
|
35137
|
+
|
|
35138
|
+
// === PERSON EXTENSION (mandatory) \u2014 User link ===
|
|
35139
|
+
builder.HasOne(e => e.User)
|
|
35140
|
+
.WithMany()
|
|
35141
|
+
.HasForeignKey(e => e.UserId)
|
|
35142
|
+
.OnDelete(DeleteBehavior.Restrict);
|
|
35143
|
+
|
|
35144
|
+
builder.HasIndex(e => new { e.TenantId, e.UserId })
|
|
35145
|
+
.IsUnique();
|
|
35146
|
+
{{else if isPersonRoleOptional}}
|
|
35147
|
+
|
|
35148
|
+
// === PERSON EXTENSION (optional) \u2014 User link ===
|
|
35149
|
+
builder.HasOne(e => e.User)
|
|
35150
|
+
.WithMany()
|
|
35151
|
+
.HasForeignKey(e => e.UserId)
|
|
35152
|
+
.OnDelete(DeleteBehavior.Restrict);
|
|
35153
|
+
|
|
35154
|
+
builder.HasIndex(e => new { e.TenantId, e.UserId })
|
|
35155
|
+
.IsUnique()
|
|
35156
|
+
.HasFilter("[UserId] IS NOT NULL");
|
|
35157
|
+
|
|
35158
|
+
// Person fields
|
|
35159
|
+
builder.Property(e => e.FirstName).HasMaxLength(200);
|
|
35160
|
+
builder.Property(e => e.LastName).HasMaxLength(200);
|
|
35161
|
+
builder.Property(e => e.Email).HasMaxLength(256);
|
|
35162
|
+
builder.Property(e => e.PhoneNumber).HasMaxLength(50);
|
|
35163
|
+
{{/if}}
|
|
35164
|
+
|
|
35076
35165
|
// TODO: Add business property configurations (HasMaxLength, IsRequired, indexes)
|
|
35077
35166
|
}
|
|
35078
35167
|
}
|
|
@@ -35086,7 +35175,10 @@ public class {{name}}Configuration : IEntityTypeConfiguration<{{name}}>
|
|
|
35086
35175
|
schema,
|
|
35087
35176
|
infrastructureNamespace: config2.conventions.namespaces.infrastructure,
|
|
35088
35177
|
configNamespaceSuffix: hierarchy.infraPath ? hierarchy.infraPath.replace(/[\\/]/g, ".") : "",
|
|
35089
|
-
domainNamespace: config2.conventions.namespaces.domain
|
|
35178
|
+
domainNamespace: config2.conventions.namespaces.domain,
|
|
35179
|
+
isPersonRoleMandatory: isPersonRole && userLinkMode === "mandatory",
|
|
35180
|
+
isPersonRoleOptional: isPersonRole && userLinkMode === "optional",
|
|
35181
|
+
isPersonRole
|
|
35090
35182
|
};
|
|
35091
35183
|
const entityContent = import_handlebars.default.compile(entityTemplate)(context);
|
|
35092
35184
|
const configContent = import_handlebars.default.compile(configTemplate)(context);
|
|
@@ -36617,6 +36709,15 @@ var init_scaffold_extension = __esm({
|
|
|
36617
36709
|
enum: ["ancestors", "descendants", "both"],
|
|
36618
36710
|
description: "Direction for hierarchy traversal function (default: both)"
|
|
36619
36711
|
},
|
|
36712
|
+
isPersonRole: {
|
|
36713
|
+
type: "boolean",
|
|
36714
|
+
description: "If true, entity is a Person Extension linked to User via UserId FK. Mandatory mode: no person fields, UserId required. Optional mode: own person fields + Display* computed properties."
|
|
36715
|
+
},
|
|
36716
|
+
userLinkMode: {
|
|
36717
|
+
type: "string",
|
|
36718
|
+
enum: ["mandatory", "optional"],
|
|
36719
|
+
description: "Person Extension mode: mandatory (UserId non-nullable, no person fields) or optional (UserId nullable, own person fields + Display* computed properties)"
|
|
36720
|
+
},
|
|
36620
36721
|
codePattern: {
|
|
36621
36722
|
type: "object",
|
|
36622
36723
|
description: 'Code auto-generation pattern for this entity. When strategy != "manual", Code is auto-generated and removed from CreateDto.',
|
|
@@ -57630,7 +57731,8 @@ async function scaffoldRoutes(input, config2) {
|
|
|
57630
57731
|
await writeText(clientRoutesFile, clientRoutesContent);
|
|
57631
57732
|
}
|
|
57632
57733
|
result.files.push({ path: clientRoutesFile, content: clientRoutesContent, type: "created" });
|
|
57633
|
-
|
|
57734
|
+
const routeCount = navRoutes.length * 4;
|
|
57735
|
+
result.instructions.push(`Generated ${routeCount} routes (4 CRUD per NavRoute) for ${navRoutes.length} NavRoutes in clientRoutes format`);
|
|
57634
57736
|
result.instructions.push("");
|
|
57635
57737
|
result.instructions.push("## App.tsx Wiring Instructions");
|
|
57636
57738
|
result.instructions.push("");
|
|
@@ -57639,7 +57741,8 @@ async function scaffoldRoutes(input, config2) {
|
|
|
57639
57741
|
const routeTree = buildRouteTree(navRoutes);
|
|
57640
57742
|
result.instructions.push("### Pattern A: mergeRoutes (applicationRoutes pattern)");
|
|
57641
57743
|
result.instructions.push("");
|
|
57642
|
-
result.instructions.push("
|
|
57744
|
+
result.instructions.push("Routes are FLAT (no nested children) \u2014 compatible with `mergeRoutes()`.");
|
|
57745
|
+
result.instructions.push("Each NavRoute generates 4 routes: list, create, detail (:id), edit (:id/edit).");
|
|
57643
57746
|
result.instructions.push("");
|
|
57644
57747
|
result.instructions.push("**IMPORTANT:** Pages are lazy-loaded. Use `<Suspense fallback={<PageLoader />}>` wrapper.");
|
|
57645
57748
|
result.instructions.push("");
|
|
@@ -57651,21 +57754,23 @@ async function scaffoldRoutes(input, config2) {
|
|
|
57651
57754
|
for (const [_app, modules] of Object.entries(routeTree)) {
|
|
57652
57755
|
for (const [, moduleRoutes] of Object.entries(modules)) {
|
|
57653
57756
|
for (const route of moduleRoutes) {
|
|
57654
|
-
const
|
|
57655
|
-
if (
|
|
57656
|
-
for (const
|
|
57657
|
-
if (!importedComponents.has(
|
|
57658
|
-
importedComponents.add(
|
|
57659
|
-
result.instructions.push(`const ${
|
|
57660
|
-
result.instructions.push(` import('${
|
|
57757
|
+
const discovery = pageFiles.get(route.navRoute);
|
|
57758
|
+
if (discovery) {
|
|
57759
|
+
for (const page of [discovery.list, discovery.create, discovery.detail, discovery.edit]) {
|
|
57760
|
+
if (page && !importedComponents.has(page.componentName)) {
|
|
57761
|
+
importedComponents.add(page.componentName);
|
|
57762
|
+
result.instructions.push(`const ${page.componentName} = lazy(() =>`);
|
|
57763
|
+
result.instructions.push(` import('${page.importPath}').then(m => ({ default: m.${page.componentName} }))`);
|
|
57661
57764
|
result.instructions.push(");");
|
|
57662
57765
|
}
|
|
57663
57766
|
}
|
|
57664
57767
|
} else {
|
|
57665
|
-
const
|
|
57666
|
-
|
|
57667
|
-
importedComponents.
|
|
57668
|
-
|
|
57768
|
+
const { entityPlural, entitySingular, basePath } = deriveEntityNames(route.navRoute);
|
|
57769
|
+
for (const comp of [`${entityPlural}Page`, `${entitySingular}CreatePage`, `${entitySingular}DetailPage`, `${entitySingular}EditPage`]) {
|
|
57770
|
+
if (!importedComponents.has(comp)) {
|
|
57771
|
+
importedComponents.add(comp);
|
|
57772
|
+
result.instructions.push(`// TODO: const ${comp} = lazy(() => import('${basePath}/${comp}').then(m => ({ default: m.${comp} })));`);
|
|
57773
|
+
}
|
|
57669
57774
|
}
|
|
57670
57775
|
}
|
|
57671
57776
|
}
|
|
@@ -57678,9 +57783,17 @@ async function scaffoldRoutes(input, config2) {
|
|
|
57678
57783
|
for (const [, moduleRoutes] of Object.entries(modules)) {
|
|
57679
57784
|
for (const route of moduleRoutes) {
|
|
57680
57785
|
const modulePath = route.navRoute.split(".").slice(1).map(toKebabCase).join("/");
|
|
57681
|
-
const
|
|
57682
|
-
const
|
|
57683
|
-
|
|
57786
|
+
const discovery = pageFiles.get(route.navRoute);
|
|
57787
|
+
const { entityPlural, entitySingular } = deriveEntityNames(route.navRoute);
|
|
57788
|
+
const listComp = discovery?.list?.componentName || `${entityPlural}Page`;
|
|
57789
|
+
const createComp = discovery?.create?.componentName || `${entitySingular}CreatePage`;
|
|
57790
|
+
const detailComp = discovery?.detail?.componentName || `${entitySingular}DetailPage`;
|
|
57791
|
+
const editComp = discovery?.edit?.componentName || `${entitySingular}EditPage`;
|
|
57792
|
+
result.instructions.push(` // === ${entityPlural.toLowerCase()} (NavRoute: ${route.navRoute}) ===`);
|
|
57793
|
+
result.instructions.push(` { path: '${modulePath}', element: <Suspense fallback={<PageLoader />}><${listComp} /></Suspense> },`);
|
|
57794
|
+
result.instructions.push(` { path: '${modulePath}/create', element: <Suspense fallback={<PageLoader />}><${createComp} /></Suspense> },`);
|
|
57795
|
+
result.instructions.push(` { path: '${modulePath}/:id', element: <Suspense fallback={<PageLoader />}><${detailComp} /></Suspense> },`);
|
|
57796
|
+
result.instructions.push(` { path: '${modulePath}/:id/edit', element: <Suspense fallback={<PageLoader />}><${editComp} /></Suspense> },`);
|
|
57684
57797
|
}
|
|
57685
57798
|
}
|
|
57686
57799
|
result.instructions.push(" ],");
|
|
@@ -57703,9 +57816,16 @@ async function scaffoldRoutes(input, config2) {
|
|
|
57703
57816
|
for (const [, moduleRoutes] of Object.entries(modules)) {
|
|
57704
57817
|
for (const route of moduleRoutes) {
|
|
57705
57818
|
const modulePath = route.navRoute.split(".").slice(1).map(toKebabCase).join("/");
|
|
57706
|
-
const
|
|
57707
|
-
const
|
|
57708
|
-
|
|
57819
|
+
const discovery = pageFiles.get(route.navRoute);
|
|
57820
|
+
const { entityPlural, entitySingular } = deriveEntityNames(route.navRoute);
|
|
57821
|
+
const listComp = discovery?.list?.componentName || `${entityPlural}Page`;
|
|
57822
|
+
const createComp = discovery?.create?.componentName || `${entitySingular}CreatePage`;
|
|
57823
|
+
const detailComp = discovery?.detail?.componentName || `${entitySingular}DetailPage`;
|
|
57824
|
+
const editComp = discovery?.edit?.componentName || `${entitySingular}EditPage`;
|
|
57825
|
+
result.instructions.push(`<Route path="${modulePath}" element={<Suspense fallback={<PageLoader />}><${listComp} /></Suspense>} />`);
|
|
57826
|
+
result.instructions.push(`<Route path="${modulePath}/create" element={<Suspense fallback={<PageLoader />}><${createComp} /></Suspense>} />`);
|
|
57827
|
+
result.instructions.push(`<Route path="${modulePath}/:id" element={<Suspense fallback={<PageLoader />}><${detailComp} /></Suspense>} />`);
|
|
57828
|
+
result.instructions.push(`<Route path="${modulePath}/:id/edit" element={<Suspense fallback={<PageLoader />}><${editComp} /></Suspense>} />`);
|
|
57709
57829
|
}
|
|
57710
57830
|
}
|
|
57711
57831
|
result.instructions.push("```");
|
|
@@ -57937,6 +58057,7 @@ function generateRouterConfig(routes, includeGuards) {
|
|
|
57937
58057
|
" * React Router Configuration",
|
|
57938
58058
|
" *",
|
|
57939
58059
|
" * Auto-generated by SmartStack MCP - DO NOT EDIT MANUALLY",
|
|
58060
|
+
" * Each NavRoute generates 4 flat CRUD routes (list, create, detail, edit).",
|
|
57940
58061
|
" */",
|
|
57941
58062
|
"",
|
|
57942
58063
|
"import { createBrowserRouter, RouteObject } from 'react-router-dom';",
|
|
@@ -57953,10 +58074,15 @@ function generateRouterConfig(routes, includeGuards) {
|
|
|
57953
58074
|
lines.push(`const ${name} = lazy(() => import('../layouts/${name}').then(m => ({ default: m.${name} })));`);
|
|
57954
58075
|
}
|
|
57955
58076
|
lines.push("");
|
|
57956
|
-
lines.push("// Page imports - lazy loaded
|
|
58077
|
+
lines.push("// Page imports - lazy loaded");
|
|
58078
|
+
lines.push("// Uncomment and adjust import paths for your page components:");
|
|
57957
58079
|
for (const route of routes) {
|
|
57958
|
-
const
|
|
57959
|
-
|
|
58080
|
+
const { entityPlural, entitySingular, basePath } = deriveEntityNames(route.navRoute);
|
|
58081
|
+
const pagesBasePath = basePath.replace("@/pages/", "../pages/");
|
|
58082
|
+
lines.push(`// const ${entityPlural}Page = lazy(() => import('${pagesBasePath}/${entityPlural}Page').then(m => ({ default: m.${entityPlural}Page })));`);
|
|
58083
|
+
lines.push(`// const ${entitySingular}CreatePage = lazy(() => import('${pagesBasePath}/${entitySingular}CreatePage').then(m => ({ default: m.${entitySingular}CreatePage })));`);
|
|
58084
|
+
lines.push(`// const ${entitySingular}DetailPage = lazy(() => import('${pagesBasePath}/${entitySingular}DetailPage').then(m => ({ default: m.${entitySingular}DetailPage })));`);
|
|
58085
|
+
lines.push(`// const ${entitySingular}EditPage = lazy(() => import('${pagesBasePath}/${entitySingular}EditPage').then(m => ({ default: m.${entitySingular}EditPage })));`);
|
|
57960
58086
|
}
|
|
57961
58087
|
lines.push("");
|
|
57962
58088
|
lines.push("const routes: RouteObject[] = [");
|
|
@@ -57968,23 +58094,14 @@ function generateRouterConfig(routes, includeGuards) {
|
|
|
57968
58094
|
for (const [, moduleRoutes] of Object.entries(modules)) {
|
|
57969
58095
|
for (const route of moduleRoutes) {
|
|
57970
58096
|
const modulePath = route.navRoute.split(".").slice(1).map(toKebabCase).join("/");
|
|
57971
|
-
const
|
|
57972
|
-
|
|
57973
|
-
|
|
57974
|
-
|
|
57975
|
-
|
|
57976
|
-
|
|
57977
|
-
|
|
57978
|
-
|
|
57979
|
-
lines.push(` </PermissionGuard>`);
|
|
57980
|
-
lines.push(` ),`);
|
|
57981
|
-
lines.push(" },");
|
|
57982
|
-
} else {
|
|
57983
|
-
lines.push(" {");
|
|
57984
|
-
lines.push(` path: '${modulePath || ""}',`);
|
|
57985
|
-
lines.push(` element: <div>TODO: ${pageName}Page</div>,`);
|
|
57986
|
-
lines.push(" },");
|
|
57987
|
-
}
|
|
58097
|
+
const { entityPlural, entitySingular } = deriveEntityNames(route.navRoute);
|
|
58098
|
+
const permGuardOpen = includeGuards && route.permissions.length > 0 ? `<PermissionGuard permissions={ROUTES['${route.navRoute}'].permissions}>` : "";
|
|
58099
|
+
const permGuardClose = permGuardOpen ? "</PermissionGuard>" : "";
|
|
58100
|
+
lines.push(` // === ${entityPlural.toLowerCase()} (NavRoute: ${route.navRoute}) ===`);
|
|
58101
|
+
lines.push(` // TODO: { path: '${modulePath}', element: <Suspense fallback={<PageLoader />}>${permGuardOpen}<${entityPlural}Page />${permGuardClose}</Suspense> },`);
|
|
58102
|
+
lines.push(` // TODO: { path: '${modulePath}/create', element: <Suspense fallback={<PageLoader />}>${permGuardOpen}<${entitySingular}CreatePage />${permGuardClose}</Suspense> },`);
|
|
58103
|
+
lines.push(` // TODO: { path: '${modulePath}/:id', element: <Suspense fallback={<PageLoader />}>${permGuardOpen}<${entitySingular}DetailPage />${permGuardClose}</Suspense> },`);
|
|
58104
|
+
lines.push(` // TODO: { path: '${modulePath}/:id/edit', element: <Suspense fallback={<PageLoader />}>${permGuardOpen}<${entitySingular}EditPage />${permGuardClose}</Suspense> },`);
|
|
57988
58105
|
}
|
|
57989
58106
|
}
|
|
57990
58107
|
lines.push(" ],");
|
|
@@ -58154,14 +58271,24 @@ async function discoverPageFiles(webPath, routes) {
|
|
|
58154
58271
|
const moduleDir = moduleParts.join("/");
|
|
58155
58272
|
const pagesDir = path19.join(webPath, "src", "pages", app, moduleDir || "");
|
|
58156
58273
|
try {
|
|
58157
|
-
const
|
|
58158
|
-
if (
|
|
58159
|
-
const
|
|
58274
|
+
const files = await glob("*Page.tsx", { cwd: pagesDir, absolute: false });
|
|
58275
|
+
if (files.length > 0) {
|
|
58276
|
+
const discovery = {};
|
|
58277
|
+
for (const f of files) {
|
|
58160
58278
|
const componentName = f.replace(".tsx", "");
|
|
58161
58279
|
const importPath = `@/pages/${app}/${moduleDir ? moduleDir + "/" : ""}${componentName}`;
|
|
58162
|
-
|
|
58163
|
-
|
|
58164
|
-
|
|
58280
|
+
const entry = { importPath, componentName };
|
|
58281
|
+
if (componentName.endsWith("CreatePage")) {
|
|
58282
|
+
discovery.create = entry;
|
|
58283
|
+
} else if (componentName.endsWith("EditPage")) {
|
|
58284
|
+
discovery.edit = entry;
|
|
58285
|
+
} else if (componentName.endsWith("DetailPage")) {
|
|
58286
|
+
discovery.detail = entry;
|
|
58287
|
+
} else {
|
|
58288
|
+
discovery.list = entry;
|
|
58289
|
+
}
|
|
58290
|
+
}
|
|
58291
|
+
pageMap.set(route.navRoute, discovery);
|
|
58165
58292
|
}
|
|
58166
58293
|
} catch {
|
|
58167
58294
|
logger.debug(`No pages found for route ${route.navRoute} at ${pagesDir}`);
|
|
@@ -58178,9 +58305,8 @@ function generateClientRoutesConfig(routes, pageFiles, includeGuards) {
|
|
|
58178
58305
|
" * Auto-generated by SmartStack MCP - DO NOT EDIT MANUALLY",
|
|
58179
58306
|
' * Run `scaffold_routes` with outputFormat: "clientRoutes" to regenerate.',
|
|
58180
58307
|
" *",
|
|
58181
|
-
" * These routes
|
|
58182
|
-
" *
|
|
58183
|
-
" * Routes must be added in BOTH the standard block and the tenant-prefixed block.",
|
|
58308
|
+
" * These routes are FLAT (not nested children) \u2014 compatible with mergeRoutes().",
|
|
58309
|
+
" * Each NavRoute generates up to 4 CRUD routes: list, create, detail, edit.",
|
|
58184
58310
|
" */",
|
|
58185
58311
|
"",
|
|
58186
58312
|
"import type { RouteObject } from 'react-router-dom';",
|
|
@@ -58196,22 +58322,32 @@ function generateClientRoutesConfig(routes, pageFiles, includeGuards) {
|
|
|
58196
58322
|
lines.push("");
|
|
58197
58323
|
const importedComponents = /* @__PURE__ */ new Set();
|
|
58198
58324
|
for (const route of routes) {
|
|
58199
|
-
const
|
|
58200
|
-
if (
|
|
58201
|
-
for (const
|
|
58202
|
-
if (!importedComponents.has(
|
|
58203
|
-
|
|
58204
|
-
lines.push(`
|
|
58325
|
+
const discovery = pageFiles.get(route.navRoute);
|
|
58326
|
+
if (discovery) {
|
|
58327
|
+
for (const page of [discovery.list, discovery.create, discovery.detail, discovery.edit]) {
|
|
58328
|
+
if (page && !importedComponents.has(page.componentName)) {
|
|
58329
|
+
importedComponents.add(page.componentName);
|
|
58330
|
+
lines.push(`const ${page.componentName} = lazy(() =>`);
|
|
58331
|
+
lines.push(` import('${page.importPath}').then(m => ({ default: m.${page.componentName} }))`);
|
|
58205
58332
|
lines.push(");");
|
|
58206
|
-
importedComponents.add(entry.componentName);
|
|
58207
58333
|
}
|
|
58208
58334
|
}
|
|
58209
58335
|
}
|
|
58210
58336
|
}
|
|
58211
58337
|
for (const route of routes) {
|
|
58212
|
-
|
|
58213
|
-
|
|
58214
|
-
|
|
58338
|
+
const discovery = pageFiles.get(route.navRoute);
|
|
58339
|
+
const { entityPlural, entitySingular, basePath } = deriveEntityNames(route.navRoute);
|
|
58340
|
+
if (!discovery?.list) {
|
|
58341
|
+
lines.push(`// TODO: const ${entityPlural}Page = lazy(() => import('${basePath}/${entityPlural}Page').then(m => ({ default: m.${entityPlural}Page })));`);
|
|
58342
|
+
}
|
|
58343
|
+
if (!discovery?.create) {
|
|
58344
|
+
lines.push(`// TODO: const ${entitySingular}CreatePage = lazy(() => import('${basePath}/${entitySingular}CreatePage').then(m => ({ default: m.${entitySingular}CreatePage })));`);
|
|
58345
|
+
}
|
|
58346
|
+
if (!discovery?.detail) {
|
|
58347
|
+
lines.push(`// TODO: const ${entitySingular}DetailPage = lazy(() => import('${basePath}/${entitySingular}DetailPage').then(m => ({ default: m.${entitySingular}DetailPage })));`);
|
|
58348
|
+
}
|
|
58349
|
+
if (!discovery?.edit) {
|
|
58350
|
+
lines.push(`// TODO: const ${entitySingular}EditPage = lazy(() => import('${basePath}/${entitySingular}EditPage').then(m => ({ default: m.${entitySingular}EditPage })));`);
|
|
58215
58351
|
}
|
|
58216
58352
|
}
|
|
58217
58353
|
lines.push("");
|
|
@@ -58220,30 +58356,44 @@ function generateClientRoutesConfig(routes, pageFiles, includeGuards) {
|
|
|
58220
58356
|
const layoutName = getLayoutName(app);
|
|
58221
58357
|
lines.push("/**");
|
|
58222
58358
|
lines.push(` * Routes for ${appUpper} application`);
|
|
58223
|
-
lines.push(` *
|
|
58359
|
+
lines.push(` * Flat routes for mergeRoutes() \u2014 children of <Route path="/${app}" element={<${layoutName} />}>`);
|
|
58224
58360
|
lines.push(" */");
|
|
58225
58361
|
lines.push(`export const ${app}Routes: RouteObject[] = [`);
|
|
58226
58362
|
for (const [, moduleRoutes] of Object.entries(modules)) {
|
|
58227
58363
|
for (const route of moduleRoutes) {
|
|
58228
58364
|
const modulePath = route.navRoute.split(".").slice(1).map(toKebabCase).join("/");
|
|
58229
|
-
const
|
|
58230
|
-
const
|
|
58231
|
-
const
|
|
58232
|
-
const
|
|
58233
|
-
|
|
58234
|
-
|
|
58235
|
-
|
|
58236
|
-
|
|
58237
|
-
|
|
58238
|
-
|
|
58239
|
-
|
|
58240
|
-
|
|
58241
|
-
|
|
58365
|
+
const discovery = pageFiles.get(route.navRoute);
|
|
58366
|
+
const { entityPlural, entitySingular } = deriveEntityNames(route.navRoute);
|
|
58367
|
+
const permGuardOpen = includeGuards && route.permissions.length > 0 ? `<PermissionGuard permissions={ROUTES['${route.navRoute}'].permissions}>` : "";
|
|
58368
|
+
const permGuardClose = permGuardOpen ? "</PermissionGuard>" : "";
|
|
58369
|
+
lines.push(` // === ${entityPlural.toLowerCase()} (NavRoute: ${route.navRoute}) ===`);
|
|
58370
|
+
const listComp = discovery?.list?.componentName || `${entityPlural}Page`;
|
|
58371
|
+
const listElement = discovery?.list ? `<Suspense fallback={<PageLoader />}>${permGuardOpen}<${listComp} />${permGuardClose}</Suspense>` : void 0;
|
|
58372
|
+
if (listElement) {
|
|
58373
|
+
lines.push(` { path: '${modulePath}', element: ${listElement} },`);
|
|
58374
|
+
} else {
|
|
58375
|
+
lines.push(` // TODO: { path: '${modulePath}', element: <${listComp} /> },`);
|
|
58376
|
+
}
|
|
58377
|
+
const createComp = discovery?.create?.componentName || `${entitySingular}CreatePage`;
|
|
58378
|
+
const createElement = discovery?.create ? `<Suspense fallback={<PageLoader />}>${permGuardOpen}<${createComp} />${permGuardClose}</Suspense>` : void 0;
|
|
58379
|
+
if (createElement) {
|
|
58380
|
+
lines.push(` { path: '${modulePath}/create', element: ${createElement} },`);
|
|
58381
|
+
} else {
|
|
58382
|
+
lines.push(` // TODO: { path: '${modulePath}/create', element: <${createComp} /> },`);
|
|
58383
|
+
}
|
|
58384
|
+
const detailComp = discovery?.detail?.componentName || `${entitySingular}DetailPage`;
|
|
58385
|
+
const detailElement = discovery?.detail ? `<Suspense fallback={<PageLoader />}>${permGuardOpen}<${detailComp} />${permGuardClose}</Suspense>` : void 0;
|
|
58386
|
+
if (detailElement) {
|
|
58387
|
+
lines.push(` { path: '${modulePath}/:id', element: ${detailElement} },`);
|
|
58388
|
+
} else {
|
|
58389
|
+
lines.push(` // TODO: { path: '${modulePath}/:id', element: <${detailComp} /> },`);
|
|
58390
|
+
}
|
|
58391
|
+
const editComp = discovery?.edit?.componentName || `${entitySingular}EditPage`;
|
|
58392
|
+
const editElement = discovery?.edit ? `<Suspense fallback={<PageLoader />}>${permGuardOpen}<${editComp} />${permGuardClose}</Suspense>` : void 0;
|
|
58393
|
+
if (editElement) {
|
|
58394
|
+
lines.push(` { path: '${modulePath}/:id/edit', element: ${editElement} },`);
|
|
58242
58395
|
} else {
|
|
58243
|
-
lines.push(` {
|
|
58244
|
-
lines.push(` path: '${modulePath}',`);
|
|
58245
|
-
lines.push(` element: ${hasRealPage ? `<Suspense fallback={<PageLoader />}><${component} /></Suspense>` : `<div>TODO: ${component}</div>`},`);
|
|
58246
|
-
lines.push(` },`);
|
|
58396
|
+
lines.push(` // TODO: { path: '${modulePath}/:id/edit', element: <${editComp} /> },`);
|
|
58247
58397
|
}
|
|
58248
58398
|
}
|
|
58249
58399
|
}
|
|
@@ -58290,6 +58440,23 @@ function navRouteToUrlPath(navRoute) {
|
|
|
58290
58440
|
function capitalize(str) {
|
|
58291
58441
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
58292
58442
|
}
|
|
58443
|
+
function singularize(word) {
|
|
58444
|
+
if (word.endsWith("ies")) return word.slice(0, -3) + "y";
|
|
58445
|
+
if (word.endsWith("ses") || word.endsWith("xes") || word.endsWith("zes") || word.endsWith("ches") || word.endsWith("shes")) return word.slice(0, -2);
|
|
58446
|
+
if (word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1);
|
|
58447
|
+
return word;
|
|
58448
|
+
}
|
|
58449
|
+
function deriveEntityNames(navRoute) {
|
|
58450
|
+
const parts = navRoute.split(".");
|
|
58451
|
+
const lastSegment = parts[parts.length - 1];
|
|
58452
|
+
const entityPlural = capitalize(lastSegment);
|
|
58453
|
+
const entitySingular = singularize(entityPlural);
|
|
58454
|
+
const app = capitalize(parts[0]);
|
|
58455
|
+
const moduleParts = parts.slice(1).map(capitalize);
|
|
58456
|
+
const moduleDir = moduleParts.join("/");
|
|
58457
|
+
const basePath = `@/pages/${app}/${moduleDir}`;
|
|
58458
|
+
return { entityPlural, entitySingular, basePath };
|
|
58459
|
+
}
|
|
58293
58460
|
function formatResult5(result, input) {
|
|
58294
58461
|
const lines = [];
|
|
58295
58462
|
lines.push("# Scaffold Routes");
|
|
@@ -58461,6 +58628,7 @@ async function discoverBackendNavRoutes(structure) {
|
|
|
58461
58628
|
if (content.includes("[HttpPost]")) methods.push("POST");
|
|
58462
58629
|
if (content.includes("[HttpPut]")) methods.push("PUT");
|
|
58463
58630
|
if (content.includes("[HttpDelete]")) methods.push("DELETE");
|
|
58631
|
+
if (content.includes("[HttpPatch]")) methods.push("PATCH");
|
|
58464
58632
|
const permissions = [];
|
|
58465
58633
|
const authorizeMatches = content.matchAll(/\[Authorize\s*\(\s*[^)]*Policy\s*=\s*"([^"]+)"/g);
|
|
58466
58634
|
for (const match2 of authorizeMatches) {
|
|
@@ -58468,8 +58636,8 @@ async function discoverBackendNavRoutes(structure) {
|
|
|
58468
58636
|
}
|
|
58469
58637
|
routes.push({
|
|
58470
58638
|
navRoute: fullNavRoute,
|
|
58471
|
-
apiPath: `/api/${navRoute.
|
|
58472
|
-
webPath: `/${navRoute.
|
|
58639
|
+
apiPath: `/api/${navRoute.split(".").map(toKebabCase2).join("/")}${suffix ? `/${toKebabCase2(suffix)}` : ""}`,
|
|
58640
|
+
webPath: `/${navRoute.split(".").map(toKebabCase2).join("/")}${suffix ? `/${toKebabCase2(suffix)}` : ""}`,
|
|
58473
58641
|
permissions,
|
|
58474
58642
|
controller: controllerName,
|
|
58475
58643
|
methods
|
|
@@ -58560,14 +58728,26 @@ async function validateApiClients(webPath, backendRoutes, result) {
|
|
|
58560
58728
|
}
|
|
58561
58729
|
}
|
|
58562
58730
|
async function validateRoutes(webPath, backendRoutes, result) {
|
|
58563
|
-
const
|
|
58564
|
-
|
|
58731
|
+
const routeCandidates = ["clientRoutes.generated.tsx", "index.tsx"];
|
|
58732
|
+
let routesContent = "";
|
|
58733
|
+
for (const candidate of routeCandidates) {
|
|
58734
|
+
const candidatePath = path20.join(webPath, "src", "routes", candidate);
|
|
58735
|
+
if (await fileExists(candidatePath)) {
|
|
58736
|
+
try {
|
|
58737
|
+
routesContent = await readText(candidatePath);
|
|
58738
|
+
} catch {
|
|
58739
|
+
logger.debug(`Failed to read ${candidatePath}`);
|
|
58740
|
+
}
|
|
58741
|
+
break;
|
|
58742
|
+
}
|
|
58743
|
+
}
|
|
58744
|
+
if (!routesContent) {
|
|
58565
58745
|
result.routes.total = 0;
|
|
58566
58746
|
result.routes.missing = backendRoutes.map((r) => r.navRoute);
|
|
58567
58747
|
return;
|
|
58568
58748
|
}
|
|
58569
58749
|
try {
|
|
58570
|
-
const content =
|
|
58750
|
+
const content = routesContent;
|
|
58571
58751
|
const pathMatches = content.matchAll(/path:\s*['"`]([^'"`]+)['"`]/g);
|
|
58572
58752
|
const frontendPaths = /* @__PURE__ */ new Set();
|
|
58573
58753
|
for (const match2 of pathMatches) {
|
|
@@ -58676,7 +58856,7 @@ async function validateAppWiring(webPath, backendRoutes, result) {
|
|
|
58676
58856
|
);
|
|
58677
58857
|
}
|
|
58678
58858
|
for (const route of backendRoutes) {
|
|
58679
|
-
const modulePath = route.navRoute.split(".").slice(1).join("/");
|
|
58859
|
+
const modulePath = route.navRoute.split(".").slice(1).map(toKebabCase2).join("/");
|
|
58680
58860
|
const lastSegment = route.navRoute.split(".").pop() || "";
|
|
58681
58861
|
const pathInApp = appContent.includes(`path="${modulePath}"`) || appContent.includes(`path='${modulePath}'`) || appContent.includes(`path="${lastSegment}"`) || appContent.includes(`path='${lastSegment}'`);
|
|
58682
58862
|
if (!pathInApp) {
|
|
@@ -58720,6 +58900,9 @@ function generateRecommendations2(result) {
|
|
|
58720
58900
|
result.recommendations.push("All routes are synchronized between frontend and backend");
|
|
58721
58901
|
}
|
|
58722
58902
|
}
|
|
58903
|
+
function toKebabCase2(segment) {
|
|
58904
|
+
return segment.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
58905
|
+
}
|
|
58723
58906
|
function formatResult6(result, _input) {
|
|
58724
58907
|
const lines = [];
|
|
58725
58908
|
const statusIcon = result.valid ? "\u2705" : "\u274C";
|
|
@@ -65090,6 +65273,35 @@ builder.HasOne(e => e.User)
|
|
|
65090
65273
|
.HasForeignKey<UserExtension>(e => e.UserId);
|
|
65091
65274
|
\`\`\`
|
|
65092
65275
|
|
|
65276
|
+
### Person Extension Pattern (Person Roles)
|
|
65277
|
+
|
|
65278
|
+
When creating entities that represent person roles (Employee, Customer, Supplier...), use the Person Extension pattern to link to User instead of duplicating person data:
|
|
65279
|
+
|
|
65280
|
+
**Mandatory variant** \u2014 entity always corresponds to a User:
|
|
65281
|
+
\`\`\`csharp
|
|
65282
|
+
public class Employee : BaseEntity, ITenantEntity, IAuditableEntity
|
|
65283
|
+
{
|
|
65284
|
+
public Guid UserId { get; private set; } // FK to auth_Users (non-nullable)
|
|
65285
|
+
public User? User { get; private set; }
|
|
65286
|
+
// Business properties ONLY \u2014 no FirstName, LastName, Email
|
|
65287
|
+
}
|
|
65288
|
+
// Config: unique index on (TenantId, UserId)
|
|
65289
|
+
\`\`\`
|
|
65290
|
+
|
|
65291
|
+
**Optional variant** \u2014 entity may or may not have a User:
|
|
65292
|
+
\`\`\`csharp
|
|
65293
|
+
public class Customer : BaseEntity, ITenantEntity, IAuditableEntity
|
|
65294
|
+
{
|
|
65295
|
+
public Guid? UserId { get; private set; } // FK to auth_Users (nullable)
|
|
65296
|
+
public User? User { get; private set; }
|
|
65297
|
+
public string FirstName { get; private set; } = null!; // Own fields as fallback
|
|
65298
|
+
public string DisplayFirstName => User?.FirstName ?? FirstName; // Computed
|
|
65299
|
+
}
|
|
65300
|
+
// Config: unique filtered index on (TenantId, UserId) WHERE UserId IS NOT NULL
|
|
65301
|
+
\`\`\`
|
|
65302
|
+
|
|
65303
|
+
Use \`scaffold_extension\` with \`isPersonRole: true\` and \`userLinkMode: "mandatory" | "optional"\`.
|
|
65304
|
+
|
|
65093
65305
|
---
|
|
65094
65306
|
|
|
65095
65307
|
## 2. Migration Conventions
|