@atlashub/smartstack-cli 3.15.0 → 3.17.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 +74 -42
- package/dist/index.js.map +1 -1
- package/dist/mcp-entry.mjs +752 -53
- package/dist/mcp-entry.mjs.map +1 -1
- package/package.json +1 -1
- package/templates/agents/gitflow/finish.md +21 -3
- package/templates/agents/gitflow/start.md +14 -4
- package/templates/skills/application/templates-backend.md +12 -1
- package/templates/skills/business-analyse/html/ba-interactive.html +11 -5
- package/templates/skills/business-analyse/html/src/scripts/05-render-specs.js +11 -5
- package/templates/skills/business-analyse/references/deploy-data-build.md +25 -9
- package/templates/skills/business-analyse/references/validation-checklist.md +29 -2
- package/templates/skills/business-analyse/steps/step-03a2-analysis.md +21 -3
- package/templates/skills/business-analyse/steps/step-03b-ui.md +31 -1
- package/templates/skills/business-analyse/steps/step-03d-validate.md +7 -3
- package/templates/skills/ralph-loop/references/category-rules.md +11 -0
- package/templates/skills/ralph-loop/references/core-seed-data.md +48 -0
package/dist/mcp-entry.mjs
CHANGED
|
@@ -25774,7 +25774,14 @@ var init_types3 = __esm({
|
|
|
25774
25774
|
dbContext: external_exports.enum(["core", "extensions"]).default("core"),
|
|
25775
25775
|
baseNamespace: external_exports.string().optional(),
|
|
25776
25776
|
smartStackVersion: external_exports.string().optional(),
|
|
25777
|
-
initialized: external_exports.string().optional()
|
|
25777
|
+
initialized: external_exports.string().optional(),
|
|
25778
|
+
paths: external_exports.object({
|
|
25779
|
+
api: external_exports.string().optional(),
|
|
25780
|
+
domain: external_exports.string().optional(),
|
|
25781
|
+
application: external_exports.string().optional(),
|
|
25782
|
+
infrastructure: external_exports.string().optional(),
|
|
25783
|
+
web: external_exports.string().optional()
|
|
25784
|
+
}).optional()
|
|
25778
25785
|
});
|
|
25779
25786
|
SmartStackConfigSchema = external_exports.object({
|
|
25780
25787
|
projectPath: external_exports.string(),
|
|
@@ -25848,7 +25855,7 @@ var init_types3 = __esm({
|
|
|
25848
25855
|
});
|
|
25849
25856
|
ValidateConventionsInputSchema = external_exports.object({
|
|
25850
25857
|
path: external_exports.string().optional().describe("Project path to validate (default: SmartStack.app path)"),
|
|
25851
|
-
checks: external_exports.array(external_exports.enum(["tables", "migrations", "services", "namespaces", "entities", "tenants", "controllers", "layouts", "tabs", "hierarchies", "protected-actions", "all"])).default(["all"]).describe("Types of checks to perform")
|
|
25858
|
+
checks: external_exports.array(external_exports.enum(["tables", "migrations", "services", "namespaces", "entities", "tenants", "controllers", "layouts", "tabs", "hierarchies", "protected-actions", "permissions", "frontend-routes", "feature-json", "all"])).default(["all"]).describe("Types of checks to perform")
|
|
25852
25859
|
});
|
|
25853
25860
|
CheckMigrationsInputSchema = external_exports.object({
|
|
25854
25861
|
projectPath: external_exports.string().optional().describe("EF Core project path"),
|
|
@@ -25947,6 +25954,7 @@ var init_types3 = __esm({
|
|
|
25947
25954
|
"authorization",
|
|
25948
25955
|
"dangerous-functions",
|
|
25949
25956
|
"input-validation",
|
|
25957
|
+
"guid-empty",
|
|
25950
25958
|
"xss",
|
|
25951
25959
|
"csrf",
|
|
25952
25960
|
"logging-sensitive",
|
|
@@ -26578,21 +26586,36 @@ async function detectProject(projectPath) {
|
|
|
26578
26586
|
}
|
|
26579
26587
|
async function findSmartStackStructure(projectPath) {
|
|
26580
26588
|
const structure = { root: projectPath };
|
|
26589
|
+
const configPath = path7.join(projectPath, ".smartstack", "config.json");
|
|
26590
|
+
if (await fileExists(configPath)) {
|
|
26591
|
+
try {
|
|
26592
|
+
const configContent = JSON.parse(await readText(configPath));
|
|
26593
|
+
if (configContent.paths) {
|
|
26594
|
+
const paths = configContent.paths;
|
|
26595
|
+
if (paths.api) structure.api = path7.resolve(projectPath, paths.api);
|
|
26596
|
+
if (paths.domain) structure.domain = path7.resolve(projectPath, paths.domain);
|
|
26597
|
+
if (paths.application) structure.application = path7.resolve(projectPath, paths.application);
|
|
26598
|
+
if (paths.infrastructure) structure.infrastructure = path7.resolve(projectPath, paths.infrastructure);
|
|
26599
|
+
if (paths.web) structure.web = path7.resolve(projectPath, paths.web);
|
|
26600
|
+
}
|
|
26601
|
+
} catch {
|
|
26602
|
+
}
|
|
26603
|
+
}
|
|
26581
26604
|
const csprojFiles = await findCsprojFiles(projectPath);
|
|
26582
26605
|
for (const csproj of csprojFiles) {
|
|
26583
26606
|
const projectName = path7.basename(csproj, ".csproj").toLowerCase();
|
|
26584
26607
|
const projectDir = path7.dirname(csproj);
|
|
26585
|
-
if (projectName.includes("domain")) {
|
|
26608
|
+
if (!structure.domain && projectName.includes("domain")) {
|
|
26586
26609
|
structure.domain = projectDir;
|
|
26587
|
-
} else if (projectName.includes("application")) {
|
|
26610
|
+
} else if (!structure.application && projectName.includes("application")) {
|
|
26588
26611
|
structure.application = projectDir;
|
|
26589
|
-
} else if (projectName.includes("infrastructure")) {
|
|
26612
|
+
} else if (!structure.infrastructure && projectName.includes("infrastructure")) {
|
|
26590
26613
|
structure.infrastructure = projectDir;
|
|
26591
26614
|
} else if (projectName.includes("api.core")) {
|
|
26592
|
-
structure.apiCore = projectDir;
|
|
26593
|
-
structure.api = projectDir;
|
|
26615
|
+
if (!structure.apiCore) structure.apiCore = projectDir;
|
|
26616
|
+
if (!structure.api) structure.api = projectDir;
|
|
26594
26617
|
} else if (projectName.includes("api") && !projectName.includes("api.core")) {
|
|
26595
|
-
structure.apiExtensions = projectDir;
|
|
26618
|
+
if (!structure.apiExtensions) structure.apiExtensions = projectDir;
|
|
26596
26619
|
if (!structure.api) {
|
|
26597
26620
|
structure.api = projectDir;
|
|
26598
26621
|
}
|
|
@@ -26604,9 +26627,11 @@ async function findSmartStackStructure(projectPath) {
|
|
|
26604
26627
|
structure.migrations = migrationsPath;
|
|
26605
26628
|
}
|
|
26606
26629
|
}
|
|
26607
|
-
|
|
26608
|
-
|
|
26609
|
-
|
|
26630
|
+
if (!structure.web) {
|
|
26631
|
+
const webFolder = await findWebProjectFolder(projectPath);
|
|
26632
|
+
if (webFolder) {
|
|
26633
|
+
structure.web = webFolder;
|
|
26634
|
+
}
|
|
26610
26635
|
}
|
|
26611
26636
|
return structure;
|
|
26612
26637
|
}
|
|
@@ -26642,7 +26667,7 @@ import path8 from "path";
|
|
|
26642
26667
|
async function handleValidateConventions(args, config2) {
|
|
26643
26668
|
const input = ValidateConventionsInputSchema.parse(args);
|
|
26644
26669
|
const projectPath = input.path || config2.smartstack.projectPath;
|
|
26645
|
-
const checks = input.checks.includes("all") ? ["tables", "migrations", "services", "namespaces", "entities", "tenants", "controllers", "layouts", "tabs", "hierarchies", "protected-actions"] : input.checks;
|
|
26670
|
+
const checks = input.checks.includes("all") ? ["tables", "migrations", "services", "namespaces", "entities", "tenants", "controllers", "layouts", "tabs", "hierarchies", "protected-actions", "permissions", "frontend-routes", "feature-json"] : input.checks;
|
|
26646
26671
|
logger.info("Validating conventions", { projectPath, checks });
|
|
26647
26672
|
const result = {
|
|
26648
26673
|
valid: true,
|
|
@@ -26684,6 +26709,15 @@ async function handleValidateConventions(args, config2) {
|
|
|
26684
26709
|
if (checks.includes("protected-actions")) {
|
|
26685
26710
|
await validateProtectedActions(structure, config2, result);
|
|
26686
26711
|
}
|
|
26712
|
+
if (checks.includes("permissions")) {
|
|
26713
|
+
await validatePermissionSeeding(structure, config2, result);
|
|
26714
|
+
}
|
|
26715
|
+
if (checks.includes("frontend-routes")) {
|
|
26716
|
+
await validateFrontendRoutes(structure, config2, result);
|
|
26717
|
+
}
|
|
26718
|
+
if (checks.includes("feature-json")) {
|
|
26719
|
+
await validateFeatureJson(structure, config2, result);
|
|
26720
|
+
}
|
|
26687
26721
|
result.valid = result.errors.length === 0;
|
|
26688
26722
|
result.summary = generateSummary(result, checks);
|
|
26689
26723
|
return formatResult(result);
|
|
@@ -27066,6 +27100,7 @@ async function validateControllerRoutes(structure, _config, result) {
|
|
|
27066
27100
|
const navRouteMatch = content.match(/\[NavRoute\s*\(\s*"([^"]+)"(?:\s*,\s*Suffix\s*=\s*"([^"]+)")?\s*\)\]/);
|
|
27067
27101
|
if (navRouteMatch) {
|
|
27068
27102
|
const routePath = navRouteMatch[1];
|
|
27103
|
+
const suffix = navRouteMatch[2];
|
|
27069
27104
|
const parts = routePath.split(".");
|
|
27070
27105
|
if (parts.length < 2) {
|
|
27071
27106
|
result.warnings.push({
|
|
@@ -27086,6 +27121,28 @@ async function validateControllerRoutes(structure, _config, result) {
|
|
|
27086
27121
|
suggestion: 'NavRoute paths must be lowercase (e.g., "platform.administration.users")'
|
|
27087
27122
|
});
|
|
27088
27123
|
}
|
|
27124
|
+
const expectedRoute = `api/${routePath.replace(/\./g, "/")}${suffix ? `/${suffix}` : ""}`;
|
|
27125
|
+
const routeAttrMatch = content.match(/\[Route\s*\(\s*"([^"]+)"\s*\)\]/);
|
|
27126
|
+
if (routeAttrMatch) {
|
|
27127
|
+
const actualRoute = routeAttrMatch[1];
|
|
27128
|
+
if (actualRoute !== expectedRoute) {
|
|
27129
|
+
result.errors.push({
|
|
27130
|
+
type: "error",
|
|
27131
|
+
category: "controllers",
|
|
27132
|
+
message: `Controller "${fileName}" has [Route("${actualRoute}")] that doesn't match NavRoute "${routePath}". Expected [Route("${expectedRoute}")]`,
|
|
27133
|
+
file: path8.relative(structure.root, file),
|
|
27134
|
+
suggestion: `Change [Route] to [Route("${expectedRoute}")] to match the NavRoute convention`
|
|
27135
|
+
});
|
|
27136
|
+
}
|
|
27137
|
+
} else {
|
|
27138
|
+
result.warnings.push({
|
|
27139
|
+
type: "warning",
|
|
27140
|
+
category: "controllers",
|
|
27141
|
+
message: `Controller "${fileName}" has [NavRoute] but no explicit [Route] attribute`,
|
|
27142
|
+
file: path8.relative(structure.root, file),
|
|
27143
|
+
suggestion: `Add [Route("${expectedRoute}")] for deterministic routing`
|
|
27144
|
+
});
|
|
27145
|
+
}
|
|
27089
27146
|
}
|
|
27090
27147
|
} else if (hasHardcodedRoute) {
|
|
27091
27148
|
hardcodedRouteCount++;
|
|
@@ -27098,6 +27155,44 @@ async function validateControllerRoutes(structure, _config, result) {
|
|
|
27098
27155
|
});
|
|
27099
27156
|
}
|
|
27100
27157
|
}
|
|
27158
|
+
const routesByDirectory = /* @__PURE__ */ new Map();
|
|
27159
|
+
for (const file of controllerFiles) {
|
|
27160
|
+
const content = await readText(file);
|
|
27161
|
+
const fileName = path8.basename(file, ".cs");
|
|
27162
|
+
if (systemControllers.includes(fileName)) continue;
|
|
27163
|
+
const routeAttrMatch = content.match(/\[Route\s*\(\s*"([^"]+)"\s*\)\]/);
|
|
27164
|
+
if (!routeAttrMatch) continue;
|
|
27165
|
+
const routeValue = routeAttrMatch[1];
|
|
27166
|
+
const relativePath = path8.relative(structure.api, file);
|
|
27167
|
+
const dirParts = path8.dirname(relativePath).split(path8.sep);
|
|
27168
|
+
const moduleDir = dirParts.slice(0, Math.min(dirParts.length, 3)).join("/");
|
|
27169
|
+
if (!routesByDirectory.has(moduleDir)) {
|
|
27170
|
+
routesByDirectory.set(moduleDir, []);
|
|
27171
|
+
}
|
|
27172
|
+
routesByDirectory.get(moduleDir).push({
|
|
27173
|
+
controller: fileName,
|
|
27174
|
+
route: routeValue,
|
|
27175
|
+
file: path8.relative(structure.root, file)
|
|
27176
|
+
});
|
|
27177
|
+
}
|
|
27178
|
+
for (const [moduleDir, controllers] of routesByDirectory) {
|
|
27179
|
+
if (controllers.length < 2) continue;
|
|
27180
|
+
const routeBases = controllers.map((c) => {
|
|
27181
|
+
const parts = c.route.split("/");
|
|
27182
|
+
return parts.slice(0, -1).join("/");
|
|
27183
|
+
});
|
|
27184
|
+
const uniqueBases = [...new Set(routeBases)];
|
|
27185
|
+
if (uniqueBases.length > 1) {
|
|
27186
|
+
const routeList = controllers.map((c) => `${c.controller}: "${c.route}"`).join(", ");
|
|
27187
|
+
result.errors.push({
|
|
27188
|
+
type: "error",
|
|
27189
|
+
category: "controllers",
|
|
27190
|
+
message: `Inconsistent route bases in ${moduleDir}: [${uniqueBases.join("] vs [")}]`,
|
|
27191
|
+
file: controllers[0].file,
|
|
27192
|
+
suggestion: `All controllers in the same module should share the same route base. Found: ${routeList}`
|
|
27193
|
+
});
|
|
27194
|
+
}
|
|
27195
|
+
}
|
|
27101
27196
|
const totalControllers = controllerFiles.length;
|
|
27102
27197
|
const businessControllers = totalControllers - systemControllerCount;
|
|
27103
27198
|
const navRoutePercentage = businessControllers > 0 ? Math.round(navRouteCount / businessControllers * 100) : 0;
|
|
@@ -27553,6 +27648,344 @@ async function validateProtectedActions(structure, _config, result) {
|
|
|
27553
27648
|
}
|
|
27554
27649
|
}
|
|
27555
27650
|
}
|
|
27651
|
+
async function validatePermissionSeeding(structure, _config, result) {
|
|
27652
|
+
const searchPaths = [structure.infrastructure, structure.application].filter(Boolean);
|
|
27653
|
+
if (searchPaths.length === 0) {
|
|
27654
|
+
result.warnings.push({
|
|
27655
|
+
type: "warning",
|
|
27656
|
+
category: "permissions",
|
|
27657
|
+
message: "Infrastructure/Application projects not found, skipping permission validation"
|
|
27658
|
+
});
|
|
27659
|
+
return;
|
|
27660
|
+
}
|
|
27661
|
+
let enumParseCount = 0;
|
|
27662
|
+
let stringActionCount = 0;
|
|
27663
|
+
let typeSafeCount = 0;
|
|
27664
|
+
for (const searchPath of searchPaths) {
|
|
27665
|
+
const seedFiles = await findFiles("**/*Seed*.cs", { cwd: searchPath });
|
|
27666
|
+
const permFiles = await findFiles("**/*Permission*.cs", { cwd: searchPath });
|
|
27667
|
+
const providerFiles = await findFiles("**/*Provider*.cs", { cwd: searchPath });
|
|
27668
|
+
const allFiles = [.../* @__PURE__ */ new Set([...seedFiles, ...permFiles, ...providerFiles])];
|
|
27669
|
+
for (const file of allFiles) {
|
|
27670
|
+
const content = await readText(file);
|
|
27671
|
+
const relPath = path8.relative(structure.root, file);
|
|
27672
|
+
const lines = content.split("\n");
|
|
27673
|
+
for (let i = 0; i < lines.length; i++) {
|
|
27674
|
+
const line = lines[i];
|
|
27675
|
+
const trimmed = line.trim();
|
|
27676
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
|
|
27677
|
+
continue;
|
|
27678
|
+
}
|
|
27679
|
+
const enumParseMatch = line.match(/Enum\.Parse\s*<\s*PermissionAction\s*>\s*\(\s*"([^"]*)"\s*\)/);
|
|
27680
|
+
if (enumParseMatch) {
|
|
27681
|
+
enumParseCount++;
|
|
27682
|
+
const parsedValue = enumParseMatch[1];
|
|
27683
|
+
const isValid2 = VALID_PERMISSION_ACTIONS.includes(parsedValue);
|
|
27684
|
+
result.errors.push({
|
|
27685
|
+
type: "error",
|
|
27686
|
+
category: "permissions",
|
|
27687
|
+
message: `Enum.Parse<PermissionAction>("${parsedValue}") is an anti-pattern${!isValid2 ? ` \u2014 "${parsedValue}" is NOT a valid PermissionAction value` : ""}`,
|
|
27688
|
+
file: relPath,
|
|
27689
|
+
line: i + 1,
|
|
27690
|
+
suggestion: `Use PermissionAction.${isValid2 ? parsedValue : "Read"} directly (compile-time safe). Valid values: ${VALID_PERMISSION_ACTIONS.join(", ")}`
|
|
27691
|
+
});
|
|
27692
|
+
}
|
|
27693
|
+
const enumParseTypeofMatch = line.match(/\(\s*PermissionAction\s*\)\s*Enum\.Parse\s*\(\s*typeof\s*\(\s*PermissionAction\s*\)\s*,\s*"([^"]*)"\s*\)/);
|
|
27694
|
+
if (enumParseTypeofMatch) {
|
|
27695
|
+
enumParseCount++;
|
|
27696
|
+
const parsedValue = enumParseTypeofMatch[1];
|
|
27697
|
+
const isValid2 = VALID_PERMISSION_ACTIONS.includes(parsedValue);
|
|
27698
|
+
result.errors.push({
|
|
27699
|
+
type: "error",
|
|
27700
|
+
category: "permissions",
|
|
27701
|
+
message: `Enum.Parse(typeof(PermissionAction), "${parsedValue}") is an anti-pattern${!isValid2 ? ` \u2014 "${parsedValue}" is NOT a valid value` : ""}`,
|
|
27702
|
+
file: relPath,
|
|
27703
|
+
line: i + 1,
|
|
27704
|
+
suggestion: `Use PermissionAction.${isValid2 ? parsedValue : "Read"} directly. Valid values: ${VALID_PERMISSION_ACTIONS.join(", ")}`
|
|
27705
|
+
});
|
|
27706
|
+
}
|
|
27707
|
+
if (/Action\s*=\s*"[^"]*"/.test(line) && /[Pp]ermission/.test(content.substring(0, content.indexOf(line)))) {
|
|
27708
|
+
const stringMatch = line.match(/Action\s*=\s*"([^"]*)"/);
|
|
27709
|
+
if (stringMatch) {
|
|
27710
|
+
stringActionCount++;
|
|
27711
|
+
result.warnings.push({
|
|
27712
|
+
type: "warning",
|
|
27713
|
+
category: "permissions",
|
|
27714
|
+
message: `Permission Action assigned as string "${stringMatch[1]}" instead of enum`,
|
|
27715
|
+
file: relPath,
|
|
27716
|
+
line: i + 1,
|
|
27717
|
+
suggestion: `Use Action = PermissionAction.${stringMatch[1]} (typed enum)`
|
|
27718
|
+
});
|
|
27719
|
+
}
|
|
27720
|
+
}
|
|
27721
|
+
const typeSafeMatches = line.match(/PermissionAction\.\w+/g);
|
|
27722
|
+
if (typeSafeMatches) {
|
|
27723
|
+
for (const m of typeSafeMatches) {
|
|
27724
|
+
const value = m.replace("PermissionAction.", "");
|
|
27725
|
+
if (VALID_PERMISSION_ACTIONS.includes(value)) {
|
|
27726
|
+
typeSafeCount++;
|
|
27727
|
+
} else {
|
|
27728
|
+
result.errors.push({
|
|
27729
|
+
type: "error",
|
|
27730
|
+
category: "permissions",
|
|
27731
|
+
message: `PermissionAction.${value} is not a valid enum value`,
|
|
27732
|
+
file: relPath,
|
|
27733
|
+
line: i + 1,
|
|
27734
|
+
suggestion: `Valid PermissionAction values: ${VALID_PERMISSION_ACTIONS.join(", ")}`
|
|
27735
|
+
});
|
|
27736
|
+
}
|
|
27737
|
+
}
|
|
27738
|
+
}
|
|
27739
|
+
}
|
|
27740
|
+
}
|
|
27741
|
+
}
|
|
27742
|
+
if (enumParseCount > 0 || stringActionCount > 0 || typeSafeCount > 0) {
|
|
27743
|
+
result.warnings.push({
|
|
27744
|
+
type: "warning",
|
|
27745
|
+
category: "permissions",
|
|
27746
|
+
message: `Permission seeding summary: ${typeSafeCount} type-safe usages, ${enumParseCount} Enum.Parse anti-patterns, ${stringActionCount} string-based actions`
|
|
27747
|
+
});
|
|
27748
|
+
}
|
|
27749
|
+
if (enumParseCount > 0) {
|
|
27750
|
+
result.errors.push({
|
|
27751
|
+
type: "error",
|
|
27752
|
+
category: "permissions",
|
|
27753
|
+
message: `Found ${enumParseCount} Enum.Parse<PermissionAction> usage(s) \u2014 these cause runtime crashes if the string is invalid`,
|
|
27754
|
+
suggestion: 'Replace ALL Enum.Parse<PermissionAction>("...") with PermissionAction.{Value} for compile-time safety'
|
|
27755
|
+
});
|
|
27756
|
+
}
|
|
27757
|
+
}
|
|
27758
|
+
async function validateFrontendRoutes(structure, _config, result) {
|
|
27759
|
+
if (!structure.web) {
|
|
27760
|
+
result.warnings.push({
|
|
27761
|
+
type: "warning",
|
|
27762
|
+
category: "frontend-routes",
|
|
27763
|
+
message: "Web project not found, skipping frontend route validation"
|
|
27764
|
+
});
|
|
27765
|
+
return;
|
|
27766
|
+
}
|
|
27767
|
+
const appFiles = await findFiles("**/App.tsx", { cwd: structure.web });
|
|
27768
|
+
if (appFiles.length === 0) {
|
|
27769
|
+
result.warnings.push({
|
|
27770
|
+
type: "warning",
|
|
27771
|
+
category: "frontend-routes",
|
|
27772
|
+
message: "App.tsx not found in web project"
|
|
27773
|
+
});
|
|
27774
|
+
return;
|
|
27775
|
+
}
|
|
27776
|
+
const appContent = await readText(appFiles[0]);
|
|
27777
|
+
const hasClientRoutes = appContent.includes("clientRoutes");
|
|
27778
|
+
const hasRouteComponents = /<Route\s/.test(appContent);
|
|
27779
|
+
if (!hasClientRoutes && !hasRouteComponents) {
|
|
27780
|
+
result.errors.push({
|
|
27781
|
+
type: "error",
|
|
27782
|
+
category: "frontend-routes",
|
|
27783
|
+
message: "App.tsx has no route definitions (neither clientRoutes import nor <Route> components)",
|
|
27784
|
+
file: path8.relative(structure.root, appFiles[0]),
|
|
27785
|
+
suggestion: "Wire generated routes to App.tsx. Import clientRoutes from the generated file and render them."
|
|
27786
|
+
});
|
|
27787
|
+
return;
|
|
27788
|
+
}
|
|
27789
|
+
const seedFiles = await findFiles("**/*NavigationSeedData.cs", { cwd: structure.root });
|
|
27790
|
+
const seedNavRoutes = [];
|
|
27791
|
+
for (const file of seedFiles) {
|
|
27792
|
+
const content = await readText(file);
|
|
27793
|
+
const routeMatches = content.matchAll(/(?:route:\s*|Route\s*=\s*)["']([^"']+)["']/g);
|
|
27794
|
+
for (const match2 of routeMatches) {
|
|
27795
|
+
seedNavRoutes.push({
|
|
27796
|
+
route: match2[1],
|
|
27797
|
+
file: path8.relative(structure.root, file)
|
|
27798
|
+
});
|
|
27799
|
+
}
|
|
27800
|
+
}
|
|
27801
|
+
if (seedNavRoutes.length === 0) return;
|
|
27802
|
+
const missingRoutes = [];
|
|
27803
|
+
for (const nav of seedNavRoutes) {
|
|
27804
|
+
const routeSegments = nav.route.split("/").filter(Boolean);
|
|
27805
|
+
const lastSegment = routeSegments[routeSegments.length - 1];
|
|
27806
|
+
const moduleSegment = routeSegments.length > 1 ? routeSegments[routeSegments.length - 2] : "";
|
|
27807
|
+
const routeInApp = appContent.includes(nav.route) || appContent.includes(`path: '${nav.route}'`) || appContent.includes(`path="${nav.route}"`) || appContent.includes(`path: '/${lastSegment}'`) || appContent.includes(`path="/${lastSegment}"`) || moduleSegment && appContent.includes(`/${moduleSegment}/${lastSegment}`);
|
|
27808
|
+
if (!routeInApp) {
|
|
27809
|
+
missingRoutes.push(nav);
|
|
27810
|
+
}
|
|
27811
|
+
}
|
|
27812
|
+
if (missingRoutes.length > 0) {
|
|
27813
|
+
for (const missing of missingRoutes.slice(0, 5)) {
|
|
27814
|
+
result.errors.push({
|
|
27815
|
+
type: "error",
|
|
27816
|
+
category: "frontend-routes",
|
|
27817
|
+
message: `Navigation route "${missing.route}" from seed data not found in App.tsx`,
|
|
27818
|
+
file: path8.relative(structure.root, appFiles[0]),
|
|
27819
|
+
suggestion: `Add route for "${missing.route}" in App.tsx (defined in ${missing.file})`
|
|
27820
|
+
});
|
|
27821
|
+
}
|
|
27822
|
+
if (missingRoutes.length > 5) {
|
|
27823
|
+
result.errors.push({
|
|
27824
|
+
type: "error",
|
|
27825
|
+
category: "frontend-routes",
|
|
27826
|
+
message: `... and ${missingRoutes.length - 5} more seed data routes missing from App.tsx`
|
|
27827
|
+
});
|
|
27828
|
+
}
|
|
27829
|
+
}
|
|
27830
|
+
result.warnings.push({
|
|
27831
|
+
type: "warning",
|
|
27832
|
+
category: "frontend-routes",
|
|
27833
|
+
message: `Frontend routes summary: ${seedNavRoutes.length} seed data routes, ${missingRoutes.length} missing from App.tsx`
|
|
27834
|
+
});
|
|
27835
|
+
}
|
|
27836
|
+
async function validateFeatureJson(structure, _config, result) {
|
|
27837
|
+
const featureFiles = await findFiles("**/business-analyse/**/feature.json", { cwd: structure.root });
|
|
27838
|
+
if (featureFiles.length === 0) {
|
|
27839
|
+
result.warnings.push({
|
|
27840
|
+
type: "warning",
|
|
27841
|
+
category: "feature-json",
|
|
27842
|
+
message: "No feature.json files found, skipping feature.json validation"
|
|
27843
|
+
});
|
|
27844
|
+
return;
|
|
27845
|
+
}
|
|
27846
|
+
let appLevelFeature = null;
|
|
27847
|
+
let appLevelPath = "";
|
|
27848
|
+
for (const file of featureFiles) {
|
|
27849
|
+
const relPath = path8.relative(structure.root, file);
|
|
27850
|
+
let data;
|
|
27851
|
+
try {
|
|
27852
|
+
data = await readJson(file);
|
|
27853
|
+
} catch {
|
|
27854
|
+
result.errors.push({
|
|
27855
|
+
type: "error",
|
|
27856
|
+
category: "feature-json",
|
|
27857
|
+
message: `Invalid JSON in feature.json`,
|
|
27858
|
+
file: relPath,
|
|
27859
|
+
suggestion: "Fix the JSON syntax in this file"
|
|
27860
|
+
});
|
|
27861
|
+
continue;
|
|
27862
|
+
}
|
|
27863
|
+
if (data.scope === "application" || data.metadata?.scope === "application") {
|
|
27864
|
+
appLevelFeature = data;
|
|
27865
|
+
appLevelPath = relPath;
|
|
27866
|
+
}
|
|
27867
|
+
if (data.$schema && data.$schema.startsWith("http")) {
|
|
27868
|
+
result.errors.push({
|
|
27869
|
+
type: "error",
|
|
27870
|
+
category: "feature-json",
|
|
27871
|
+
message: `feature.json uses remote $schema URL instead of relative path`,
|
|
27872
|
+
file: relPath,
|
|
27873
|
+
suggestion: 'Use relative $schema path: "../../../schemas/feature-schema.json"'
|
|
27874
|
+
});
|
|
27875
|
+
}
|
|
27876
|
+
if (data.metadata?.language && data.metadata.language !== "fr") {
|
|
27877
|
+
result.warnings.push({
|
|
27878
|
+
type: "warning",
|
|
27879
|
+
category: "feature-json",
|
|
27880
|
+
message: `feature.json language is "${data.metadata.language}" instead of "fr"`,
|
|
27881
|
+
file: relPath,
|
|
27882
|
+
suggestion: 'SmartStack projects should use language: "fr" for consistency'
|
|
27883
|
+
});
|
|
27884
|
+
}
|
|
27885
|
+
const currentYear = (/* @__PURE__ */ new Date()).getFullYear().toString();
|
|
27886
|
+
const dates = [data.metadata?.createdAt, data.metadata?.updatedAt, data.metadata?.lastModified].filter(Boolean);
|
|
27887
|
+
for (const dateStr of dates) {
|
|
27888
|
+
if (dateStr && !dateStr.startsWith(currentYear) && !dateStr.startsWith((parseInt(currentYear) - 1).toString())) {
|
|
27889
|
+
result.warnings.push({
|
|
27890
|
+
type: "warning",
|
|
27891
|
+
category: "feature-json",
|
|
27892
|
+
message: `feature.json has date "${dateStr}" which doesn't match current year (${currentYear})`,
|
|
27893
|
+
file: relPath,
|
|
27894
|
+
suggestion: "Verify the date is correct \u2014 it may indicate a copy-paste error"
|
|
27895
|
+
});
|
|
27896
|
+
}
|
|
27897
|
+
}
|
|
27898
|
+
if (data.metadata?.lastModified && !data.metadata?.updatedAt) {
|
|
27899
|
+
result.errors.push({
|
|
27900
|
+
type: "error",
|
|
27901
|
+
category: "feature-json",
|
|
27902
|
+
message: 'feature.json uses "lastModified" instead of "updatedAt"',
|
|
27903
|
+
file: relPath,
|
|
27904
|
+
suggestion: 'Rename "lastModified" to "updatedAt" for schema compliance'
|
|
27905
|
+
});
|
|
27906
|
+
}
|
|
27907
|
+
if (data.metadata?.context && data.metadata.context !== "business" && relPath.includes("Business")) {
|
|
27908
|
+
result.warnings.push({
|
|
27909
|
+
type: "warning",
|
|
27910
|
+
category: "feature-json",
|
|
27911
|
+
message: `feature.json context is "${data.metadata.context}" but file is in Business directory`,
|
|
27912
|
+
file: relPath,
|
|
27913
|
+
suggestion: 'Use context: "business" for business domain features'
|
|
27914
|
+
});
|
|
27915
|
+
}
|
|
27916
|
+
if (data.metadata?.analysisMode && data.metadata.analysisMode !== "interactive") {
|
|
27917
|
+
result.errors.push({
|
|
27918
|
+
type: "error",
|
|
27919
|
+
category: "feature-json",
|
|
27920
|
+
message: `feature.json analysisMode is "${data.metadata.analysisMode}" instead of "interactive"`,
|
|
27921
|
+
file: relPath,
|
|
27922
|
+
suggestion: 'analysisMode must always be "interactive" (vibe_coding bypass was removed)'
|
|
27923
|
+
});
|
|
27924
|
+
}
|
|
27925
|
+
if (data.metadata?.tablePrefix) {
|
|
27926
|
+
if (!/^[a-z]{2,5}_$/.test(data.metadata.tablePrefix)) {
|
|
27927
|
+
result.errors.push({
|
|
27928
|
+
type: "error",
|
|
27929
|
+
category: "feature-json",
|
|
27930
|
+
message: `Invalid tablePrefix "${data.metadata.tablePrefix}"`,
|
|
27931
|
+
file: relPath,
|
|
27932
|
+
suggestion: "tablePrefix must match pattern ^[a-z]{2,5}_$ (e.g., rh_, fi_, crm_)"
|
|
27933
|
+
});
|
|
27934
|
+
}
|
|
27935
|
+
}
|
|
27936
|
+
if (!data.id) {
|
|
27937
|
+
result.errors.push({
|
|
27938
|
+
type: "error",
|
|
27939
|
+
category: "feature-json",
|
|
27940
|
+
message: 'feature.json missing required field "id"',
|
|
27941
|
+
file: relPath,
|
|
27942
|
+
suggestion: "Add id field with format FEAT-XXX"
|
|
27943
|
+
});
|
|
27944
|
+
} else if (!/^FEAT-\d{3,}$/.test(data.id)) {
|
|
27945
|
+
result.errors.push({
|
|
27946
|
+
type: "error",
|
|
27947
|
+
category: "feature-json",
|
|
27948
|
+
message: `feature.json id "${data.id}" does not match pattern FEAT-XXX`,
|
|
27949
|
+
file: relPath,
|
|
27950
|
+
suggestion: "Use format: FEAT-001, FEAT-002, etc."
|
|
27951
|
+
});
|
|
27952
|
+
}
|
|
27953
|
+
}
|
|
27954
|
+
if (appLevelFeature && featureFiles.length > 1) {
|
|
27955
|
+
for (const file of featureFiles) {
|
|
27956
|
+
const relPath = path8.relative(structure.root, file);
|
|
27957
|
+
if (relPath === appLevelPath) continue;
|
|
27958
|
+
try {
|
|
27959
|
+
const moduleData = await readJson(file);
|
|
27960
|
+
if (!moduleData.metadata) continue;
|
|
27961
|
+
if (appLevelFeature.metadata?.tablePrefix && moduleData.metadata.tablePrefix && moduleData.metadata.tablePrefix !== appLevelFeature.metadata.tablePrefix) {
|
|
27962
|
+
result.errors.push({
|
|
27963
|
+
type: "error",
|
|
27964
|
+
category: "feature-json",
|
|
27965
|
+
message: `Module tablePrefix "${moduleData.metadata.tablePrefix}" differs from app-level "${appLevelFeature.metadata.tablePrefix}"`,
|
|
27966
|
+
file: relPath,
|
|
27967
|
+
suggestion: `Use tablePrefix: "${appLevelFeature.metadata.tablePrefix}" to match the application-level feature.json`
|
|
27968
|
+
});
|
|
27969
|
+
}
|
|
27970
|
+
if (appLevelFeature.metadata?.language && moduleData.metadata.language && moduleData.metadata.language !== appLevelFeature.metadata.language) {
|
|
27971
|
+
result.warnings.push({
|
|
27972
|
+
type: "warning",
|
|
27973
|
+
category: "feature-json",
|
|
27974
|
+
message: `Module language "${moduleData.metadata.language}" differs from app-level "${appLevelFeature.metadata.language}"`,
|
|
27975
|
+
file: relPath,
|
|
27976
|
+
suggestion: `Use language: "${appLevelFeature.metadata.language}" for consistency`
|
|
27977
|
+
});
|
|
27978
|
+
}
|
|
27979
|
+
} catch {
|
|
27980
|
+
}
|
|
27981
|
+
}
|
|
27982
|
+
}
|
|
27983
|
+
result.warnings.push({
|
|
27984
|
+
type: "warning",
|
|
27985
|
+
category: "feature-json",
|
|
27986
|
+
message: `Feature.json summary: ${featureFiles.length} file(s) validated`
|
|
27987
|
+
});
|
|
27988
|
+
}
|
|
27556
27989
|
function generateSummary(result, checks) {
|
|
27557
27990
|
const parts = [];
|
|
27558
27991
|
parts.push(`Checks performed: ${checks.join(", ")}`);
|
|
@@ -27598,7 +28031,7 @@ function formatResult(result) {
|
|
|
27598
28031
|
}
|
|
27599
28032
|
return lines.join("\n");
|
|
27600
28033
|
}
|
|
27601
|
-
var validateConventionsTool;
|
|
28034
|
+
var validateConventionsTool, VALID_PERMISSION_ACTIONS;
|
|
27602
28035
|
var init_validate_conventions = __esm({
|
|
27603
28036
|
"src/mcp/tools/validate-conventions.ts"() {
|
|
27604
28037
|
"use strict";
|
|
@@ -27609,7 +28042,7 @@ var init_validate_conventions = __esm({
|
|
|
27609
28042
|
init_logger();
|
|
27610
28043
|
validateConventionsTool = {
|
|
27611
28044
|
name: "validate_conventions",
|
|
27612
|
-
description: "Validate AtlasHub/SmartStack conventions: SQL schemas (core/extensions), domain table prefixes (auth_, nav_, ai_, etc.), migration naming ({context}_v{version}_{sequence}_*), service interfaces (I*Service), namespace structure, controller routes (NavRoute)",
|
|
28045
|
+
description: "Validate AtlasHub/SmartStack conventions: SQL schemas (core/extensions), domain table prefixes (auth_, nav_, ai_, etc.), migration naming ({context}_v{version}_{sequence}_*), service interfaces (I*Service), namespace structure, controller routes (NavRoute), permission seeding safety",
|
|
27613
28046
|
inputSchema: {
|
|
27614
28047
|
type: "object",
|
|
27615
28048
|
properties: {
|
|
@@ -27621,7 +28054,7 @@ var init_validate_conventions = __esm({
|
|
|
27621
28054
|
type: "array",
|
|
27622
28055
|
items: {
|
|
27623
28056
|
type: "string",
|
|
27624
|
-
enum: ["tables", "migrations", "services", "namespaces", "entities", "tenants", "controllers", "layouts", "tabs", "hierarchies", "protected-actions", "all"]
|
|
28057
|
+
enum: ["tables", "migrations", "services", "namespaces", "entities", "tenants", "controllers", "layouts", "tabs", "hierarchies", "protected-actions", "permissions", "frontend-routes", "feature-json", "all"]
|
|
27625
28058
|
},
|
|
27626
28059
|
description: "Types of checks to perform",
|
|
27627
28060
|
default: ["all"]
|
|
@@ -27629,6 +28062,19 @@ var init_validate_conventions = __esm({
|
|
|
27629
28062
|
}
|
|
27630
28063
|
}
|
|
27631
28064
|
};
|
|
28065
|
+
VALID_PERMISSION_ACTIONS = [
|
|
28066
|
+
"Access",
|
|
28067
|
+
"Read",
|
|
28068
|
+
"Create",
|
|
28069
|
+
"Update",
|
|
28070
|
+
"Delete",
|
|
28071
|
+
"Export",
|
|
28072
|
+
"Import",
|
|
28073
|
+
"Approve",
|
|
28074
|
+
"Reject",
|
|
28075
|
+
"Assign",
|
|
28076
|
+
"Execute"
|
|
28077
|
+
];
|
|
27632
28078
|
}
|
|
27633
28079
|
});
|
|
27634
28080
|
|
|
@@ -34669,7 +35115,10 @@ async function scaffoldController(name, options, structure, config2, result, dry
|
|
|
34669
35115
|
const namespace = options?.namespace || (hierarchy.controllerArea ? `${config2.conventions.namespaces.api}.Controllers.${hierarchy.controllerArea}` : `${config2.conventions.namespaces.api}.Controllers`);
|
|
34670
35116
|
const navRoute = options?.navRoute;
|
|
34671
35117
|
const navRouteSuffix = options?.navRouteSuffix;
|
|
34672
|
-
const
|
|
35118
|
+
const apiRoute = navRoute ? `api/${navRoute.replace(/\./g, "/")}${navRouteSuffix ? `/${navRouteSuffix}` : ""}` : null;
|
|
35119
|
+
const routeAttribute = navRoute ? navRouteSuffix ? `[Route("${apiRoute}")]
|
|
35120
|
+
[NavRoute("${navRoute}", Suffix = "${navRouteSuffix}")]` : `[Route("${apiRoute}")]
|
|
35121
|
+
[NavRoute("${navRoute}")]` : `[Route("api/[controller]")]`;
|
|
34673
35122
|
const navRouteUsing = navRoute ? "using SmartStack.Api.Core.Routing;\n" : "";
|
|
34674
35123
|
const controllerTemplate = `using Microsoft.AspNetCore.Authorization;
|
|
34675
35124
|
using Microsoft.AspNetCore.Mvc;
|
|
@@ -34771,10 +35220,12 @@ public record Update{{name}}Request();
|
|
|
34771
35220
|
}
|
|
34772
35221
|
result.files.push({ path: controllerFilePath, content: controllerContent, type: "created" });
|
|
34773
35222
|
if (navRoute) {
|
|
34774
|
-
result.instructions.push("Controller created with NavRoute (
|
|
35223
|
+
result.instructions.push("Controller created with NavRoute + explicit Route (deterministic routing).");
|
|
34775
35224
|
result.instructions.push(`NavRoute: ${navRoute}${navRouteSuffix ? ` (Suffix: ${navRouteSuffix})` : ""}`);
|
|
35225
|
+
result.instructions.push(`API Route: ${apiRoute}`);
|
|
34776
35226
|
result.instructions.push("");
|
|
34777
|
-
result.instructions.push("The
|
|
35227
|
+
result.instructions.push("The [Route] attribute ensures deterministic API routing.");
|
|
35228
|
+
result.instructions.push("The [NavRoute] attribute integrates with the navigation/permission system.");
|
|
34778
35229
|
result.instructions.push("Ensure the navigation path exists in the database:");
|
|
34779
35230
|
result.instructions.push(` Context > Application > Module > Section matching "${navRoute}"`);
|
|
34780
35231
|
} else {
|
|
@@ -52147,6 +52598,14 @@ function generatePermissionsForNavRoute(navRoute, customActions, includeStandard
|
|
|
52147
52598
|
category: context
|
|
52148
52599
|
});
|
|
52149
52600
|
}
|
|
52601
|
+
for (const customAction of customActions) {
|
|
52602
|
+
if (!VALID_PERMISSION_ACTIONS2[customAction.toLowerCase()]) {
|
|
52603
|
+
const validActions = Object.keys(VALID_PERMISSION_ACTIONS2).join(", ");
|
|
52604
|
+
throw new Error(
|
|
52605
|
+
`Invalid custom action: "${customAction}". Valid PermissionAction values: ${validActions}.`
|
|
52606
|
+
);
|
|
52607
|
+
}
|
|
52608
|
+
}
|
|
52150
52609
|
const actions = includeStandardActions ? [...STANDARD_ACTIONS, ...customActions] : customActions;
|
|
52151
52610
|
for (const action of actions) {
|
|
52152
52611
|
const code = `${navRoute}.${action}`;
|
|
@@ -52248,14 +52707,24 @@ function formatPermissionDescription(navRoute, action) {
|
|
|
52248
52707
|
}
|
|
52249
52708
|
function getActionVerb(action) {
|
|
52250
52709
|
const verbs = {
|
|
52710
|
+
"access": "Access",
|
|
52251
52711
|
"read": "View",
|
|
52252
52712
|
"create": "Create",
|
|
52253
52713
|
"update": "Update",
|
|
52254
52714
|
"delete": "Delete",
|
|
52255
52715
|
"export": "Export",
|
|
52256
|
-
"import": "Import"
|
|
52716
|
+
"import": "Import",
|
|
52717
|
+
"approve": "Approve",
|
|
52718
|
+
"reject": "Reject",
|
|
52719
|
+
"assign": "Assign",
|
|
52720
|
+
"execute": "Execute"
|
|
52257
52721
|
};
|
|
52258
|
-
|
|
52722
|
+
const verb = verbs[action.toLowerCase()];
|
|
52723
|
+
if (!verb) {
|
|
52724
|
+
logger.warn(`Unknown permission action verb: "${action}". This may cause runtime errors.`);
|
|
52725
|
+
return action.charAt(0).toUpperCase() + action.slice(1);
|
|
52726
|
+
}
|
|
52727
|
+
return verb;
|
|
52259
52728
|
}
|
|
52260
52729
|
function formatPermissionsReport(permissions) {
|
|
52261
52730
|
let report = "";
|
|
@@ -52324,17 +52793,14 @@ function generatePlaceholderGuid() {
|
|
|
52324
52793
|
return "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX";
|
|
52325
52794
|
}
|
|
52326
52795
|
function getActionEnum(action) {
|
|
52327
|
-
const
|
|
52328
|
-
|
|
52329
|
-
"
|
|
52330
|
-
|
|
52331
|
-
|
|
52332
|
-
|
|
52333
|
-
|
|
52334
|
-
|
|
52335
|
-
"import": "Import"
|
|
52336
|
-
};
|
|
52337
|
-
return actionMap[action.toLowerCase()] || action.charAt(0).toUpperCase() + action.slice(1);
|
|
52796
|
+
const result = VALID_PERMISSION_ACTIONS2[action.toLowerCase()];
|
|
52797
|
+
if (!result) {
|
|
52798
|
+
const validActions = Object.keys(VALID_PERMISSION_ACTIONS2).join(", ");
|
|
52799
|
+
throw new Error(
|
|
52800
|
+
`Invalid PermissionAction: "${action}". Valid actions: ${validActions}. Do NOT use Enum.Parse<PermissionAction>() with arbitrary strings \u2014 use typed enum values directly.`
|
|
52801
|
+
);
|
|
52802
|
+
}
|
|
52803
|
+
return result;
|
|
52338
52804
|
}
|
|
52339
52805
|
async function readDirectoryRecursive(dir) {
|
|
52340
52806
|
const files = [];
|
|
@@ -52352,7 +52818,7 @@ async function readDirectoryRecursive(dir) {
|
|
|
52352
52818
|
}
|
|
52353
52819
|
return files;
|
|
52354
52820
|
}
|
|
52355
|
-
var HTTP_METHOD_TO_ACTION, STANDARD_ACTIONS, generatePermissionsTool;
|
|
52821
|
+
var HTTP_METHOD_TO_ACTION, STANDARD_ACTIONS, generatePermissionsTool, VALID_PERMISSION_ACTIONS2;
|
|
52356
52822
|
var init_generate_permissions = __esm({
|
|
52357
52823
|
"src/mcp/tools/generate-permissions.ts"() {
|
|
52358
52824
|
"use strict";
|
|
@@ -52415,6 +52881,30 @@ After adding to PermissionConfiguration.cs, run: dotnet ef migrations add <Migra
|
|
|
52415
52881
|
}
|
|
52416
52882
|
}
|
|
52417
52883
|
};
|
|
52884
|
+
VALID_PERMISSION_ACTIONS2 = {
|
|
52885
|
+
"access": "Access",
|
|
52886
|
+
// 0 - Wildcard permissions only
|
|
52887
|
+
"read": "Read",
|
|
52888
|
+
// 1
|
|
52889
|
+
"create": "Create",
|
|
52890
|
+
// 2
|
|
52891
|
+
"update": "Update",
|
|
52892
|
+
// 3
|
|
52893
|
+
"delete": "Delete",
|
|
52894
|
+
// 4
|
|
52895
|
+
"export": "Export",
|
|
52896
|
+
// 5
|
|
52897
|
+
"import": "Import",
|
|
52898
|
+
// 6
|
|
52899
|
+
"approve": "Approve",
|
|
52900
|
+
// 7
|
|
52901
|
+
"reject": "Reject",
|
|
52902
|
+
// 8
|
|
52903
|
+
"assign": "Assign",
|
|
52904
|
+
// 9
|
|
52905
|
+
"execute": "Execute"
|
|
52906
|
+
// 10
|
|
52907
|
+
};
|
|
52418
52908
|
}
|
|
52419
52909
|
});
|
|
52420
52910
|
|
|
@@ -56516,12 +57006,23 @@ async function scaffoldRoutes(input, config2) {
|
|
|
56516
57006
|
const structure = await findSmartStackStructure(projectRoot);
|
|
56517
57007
|
const webPath = structure.web || path19.join(projectRoot, "web");
|
|
56518
57008
|
const routesPath = options?.outputPath || path19.join(webPath, "src", "routes");
|
|
56519
|
-
const
|
|
57009
|
+
const routeWarnings = [];
|
|
57010
|
+
const navRoutes = await discoverNavRoutes(structure, scope, routeWarnings);
|
|
56520
57011
|
if (navRoutes.length === 0) {
|
|
56521
57012
|
result.success = false;
|
|
56522
57013
|
result.instructions.push("No NavRoute attributes found in controllers");
|
|
56523
57014
|
return result;
|
|
56524
57015
|
}
|
|
57016
|
+
if (routeWarnings.length > 0) {
|
|
57017
|
+
result.instructions.push("");
|
|
57018
|
+
result.instructions.push("### Route/NavRoute Mismatches Detected:");
|
|
57019
|
+
for (const warning of routeWarnings) {
|
|
57020
|
+
result.instructions.push(warning);
|
|
57021
|
+
}
|
|
57022
|
+
result.instructions.push("");
|
|
57023
|
+
result.instructions.push('Fix: Update [Route] attributes to match the NavRoute convention: api/{navRoute.replace(".", "/")}');
|
|
57024
|
+
result.instructions.push("");
|
|
57025
|
+
}
|
|
56525
57026
|
if (generateRegistry) {
|
|
56526
57027
|
const registryContent = generateNavRouteRegistry(navRoutes);
|
|
56527
57028
|
const registryFile = path19.join(routesPath, "navRoutes.generated.ts");
|
|
@@ -56602,7 +57103,7 @@ async function scaffoldRoutes(input, config2) {
|
|
|
56602
57103
|
}
|
|
56603
57104
|
return result;
|
|
56604
57105
|
}
|
|
56605
|
-
async function discoverNavRoutes(structure, scope) {
|
|
57106
|
+
async function discoverNavRoutes(structure, scope, warnings) {
|
|
56606
57107
|
const routes = [];
|
|
56607
57108
|
const apiPaths = [];
|
|
56608
57109
|
if (scope === "all" || scope === "platform") {
|
|
@@ -56659,6 +57160,7 @@ async function discoverNavRoutes(structure, scope) {
|
|
|
56659
57160
|
permissions.push(match2[1]);
|
|
56660
57161
|
}
|
|
56661
57162
|
const fullNavRoute = suffix ? `${navRoute}.${suffix}` : navRoute;
|
|
57163
|
+
const expectedRoute = `api/${navRoute.replace(/\./g, "/")}${suffix ? `/${suffix}` : ""}`;
|
|
56662
57164
|
routes.push({
|
|
56663
57165
|
navRoute: fullNavRoute,
|
|
56664
57166
|
apiPath: `/api/${navRoute.replace(/\./g, "/")}${suffix ? `/${suffix}` : ""}`,
|
|
@@ -56667,6 +57169,15 @@ async function discoverNavRoutes(structure, scope) {
|
|
|
56667
57169
|
controller: controllerName,
|
|
56668
57170
|
methods
|
|
56669
57171
|
});
|
|
57172
|
+
const routeAttrMatch = content.match(/\[Route\s*\(\s*"([^"]+)"\s*\)\]/);
|
|
57173
|
+
if (routeAttrMatch && warnings) {
|
|
57174
|
+
const actualRoute = routeAttrMatch[1];
|
|
57175
|
+
if (actualRoute !== expectedRoute && actualRoute !== "api/[controller]") {
|
|
57176
|
+
warnings.push(
|
|
57177
|
+
`WARNING: ${controllerName}Controller has [Route("${actualRoute}")] that doesn't match NavRoute "${navRoute}". Expected [Route("${expectedRoute}")]`
|
|
57178
|
+
);
|
|
57179
|
+
}
|
|
57180
|
+
}
|
|
56670
57181
|
}
|
|
56671
57182
|
} catch {
|
|
56672
57183
|
logger.debug(`Failed to parse controller: ${file}`);
|
|
@@ -57245,10 +57756,10 @@ import path20 from "path";
|
|
|
57245
57756
|
async function handleValidateFrontendRoutes(args, config2) {
|
|
57246
57757
|
const input = ValidateFrontendRoutesInputSchema.parse(args);
|
|
57247
57758
|
logger.info("Validating frontend routes", { scope: input.scope });
|
|
57248
|
-
const result = await
|
|
57759
|
+
const result = await validateFrontendRoutes2(input, config2);
|
|
57249
57760
|
return formatResult6(result, input);
|
|
57250
57761
|
}
|
|
57251
|
-
async function
|
|
57762
|
+
async function validateFrontendRoutes2(input, config2) {
|
|
57252
57763
|
const result = {
|
|
57253
57764
|
valid: true,
|
|
57254
57765
|
registry: {
|
|
@@ -57480,6 +57991,21 @@ async function validateAppWiring(webPath, backendRoutes, result) {
|
|
|
57480
57991
|
break;
|
|
57481
57992
|
}
|
|
57482
57993
|
}
|
|
57994
|
+
if (!appFilePath) {
|
|
57995
|
+
try {
|
|
57996
|
+
const deepSearch = await glob("**/App.tsx", {
|
|
57997
|
+
cwd: webPath,
|
|
57998
|
+
absolute: true,
|
|
57999
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"]
|
|
58000
|
+
});
|
|
58001
|
+
if (deepSearch.length > 0) {
|
|
58002
|
+
appFilePath = deepSearch[0];
|
|
58003
|
+
appContent = await readText(appFilePath);
|
|
58004
|
+
}
|
|
58005
|
+
} catch {
|
|
58006
|
+
logger.debug("Fallback App.tsx search failed");
|
|
58007
|
+
}
|
|
58008
|
+
}
|
|
57483
58009
|
result.appWiring = {
|
|
57484
58010
|
exists: !!appFilePath,
|
|
57485
58011
|
routesImported: false,
|
|
@@ -57487,7 +58013,10 @@ async function validateAppWiring(webPath, backendRoutes, result) {
|
|
|
57487
58013
|
issues: []
|
|
57488
58014
|
};
|
|
57489
58015
|
if (!appFilePath || !appContent) {
|
|
57490
|
-
|
|
58016
|
+
const searchedPaths = appCandidates.map((c) => path20.join(webPath, "src", c)).join(", ");
|
|
58017
|
+
result.appWiring.issues.push(
|
|
58018
|
+
`No App.tsx or main.tsx found. Searched: ${searchedPaths} and ${webPath}/**/App.tsx`
|
|
58019
|
+
);
|
|
57491
58020
|
return;
|
|
57492
58021
|
}
|
|
57493
58022
|
const hasClientRoutesImport = appContent.includes("clientRoutes.generated");
|
|
@@ -59528,7 +60057,8 @@ async function handleValidateSecurity(args, config2) {
|
|
|
59528
60057
|
"tenant-isolation",
|
|
59529
60058
|
"authorization",
|
|
59530
60059
|
"dangerous-functions",
|
|
59531
|
-
"input-validation"
|
|
60060
|
+
"input-validation",
|
|
60061
|
+
"guid-empty"
|
|
59532
60062
|
] : input.checks;
|
|
59533
60063
|
logger.info("Validating security", { projectPath, checks: checksToRun });
|
|
59534
60064
|
const result = {
|
|
@@ -59561,6 +60091,9 @@ async function handleValidateSecurity(args, config2) {
|
|
|
59561
60091
|
if (checksToRun.includes("input-validation")) {
|
|
59562
60092
|
await checkInputValidation(structure, result);
|
|
59563
60093
|
}
|
|
60094
|
+
if (checksToRun.includes("guid-empty")) {
|
|
60095
|
+
await checkGuidEmpty(structure, result);
|
|
60096
|
+
}
|
|
59564
60097
|
result.stats.blocking = result.findings.filter((f) => f.severity === "blocking").length;
|
|
59565
60098
|
result.stats.critical = result.findings.filter((f) => f.severity === "critical").length;
|
|
59566
60099
|
result.stats.warning = result.findings.filter((f) => f.severity === "warning").length;
|
|
@@ -59688,22 +60221,60 @@ async function checkTenantIsolation(structure, result) {
|
|
|
59688
60221
|
const serviceFiles = await findFiles("**/*Service.cs", { cwd: structure.application });
|
|
59689
60222
|
for (const file of serviceFiles) {
|
|
59690
60223
|
const content = await readText(file);
|
|
59691
|
-
const
|
|
60224
|
+
const lines = content.split("\n");
|
|
60225
|
+
const tenantDbSets = /* @__PURE__ */ new Set();
|
|
60226
|
+
for (const entity of tenantEntities) {
|
|
60227
|
+
tenantDbSets.add(entity + "s");
|
|
60228
|
+
tenantDbSets.add(entity + "es");
|
|
60229
|
+
tenantDbSets.add(entity + "ies");
|
|
60230
|
+
tenantDbSets.add(entity);
|
|
60231
|
+
}
|
|
60232
|
+
const dbSetAccessPattern = /_context\.(\w+)/g;
|
|
59692
60233
|
let match2;
|
|
59693
|
-
while ((match2 =
|
|
59694
|
-
const
|
|
59695
|
-
if (
|
|
59696
|
-
|
|
59697
|
-
|
|
59698
|
-
|
|
59699
|
-
|
|
59700
|
-
|
|
59701
|
-
|
|
59702
|
-
|
|
59703
|
-
|
|
59704
|
-
|
|
59705
|
-
|
|
59706
|
-
|
|
60234
|
+
while ((match2 = dbSetAccessPattern.exec(content)) !== null) {
|
|
60235
|
+
const dbSetName = match2[1];
|
|
60236
|
+
if (tenantEntities.length > 0 && !tenantDbSets.has(dbSetName)) {
|
|
60237
|
+
continue;
|
|
60238
|
+
}
|
|
60239
|
+
const chainStart = match2.index;
|
|
60240
|
+
const semicolonIndex = content.indexOf(";", chainStart);
|
|
60241
|
+
if (semicolonIndex === -1) continue;
|
|
60242
|
+
const fullChain = content.substring(chainStart, semicolonIndex);
|
|
60243
|
+
if (fullChain.includes("TenantId")) continue;
|
|
60244
|
+
if (/\.Add\s*\(/.test(fullChain) || /\.Remove\s*\(/.test(fullChain) || /\.Update\s*\(/.test(fullChain)) continue;
|
|
60245
|
+
if (!fullChain.includes(".") || !fullChain.includes("(")) continue;
|
|
60246
|
+
const lineNumber = getLineNumber(content, match2.index);
|
|
60247
|
+
const lineContent = lines[lineNumber - 1]?.trim() || "";
|
|
60248
|
+
result.findings.push({
|
|
60249
|
+
severity: "blocking",
|
|
60250
|
+
category: "tenant-isolation",
|
|
60251
|
+
message: `Query on _context.${dbSetName} without TenantId filter`,
|
|
60252
|
+
file: path23.relative(structure.root, file),
|
|
60253
|
+
line: lineNumber,
|
|
60254
|
+
code: truncateCode(lineContent),
|
|
60255
|
+
suggestion: "Add .Where(x => x.TenantId == tenantId) to the query chain, or ensure a global query filter is applied.",
|
|
60256
|
+
cweId: "CWE-639"
|
|
60257
|
+
});
|
|
60258
|
+
}
|
|
60259
|
+
for (const entity of tenantEntities) {
|
|
60260
|
+
const newEntityPattern = new RegExp(`new\\s+${entity}\\s*\\{([^}]*)\\}`, "gs");
|
|
60261
|
+
let newMatch;
|
|
60262
|
+
while ((newMatch = newEntityPattern.exec(content)) !== null) {
|
|
60263
|
+
const objectInitializer = newMatch[1];
|
|
60264
|
+
if (!objectInitializer.includes("TenantId")) {
|
|
60265
|
+
const lineNumber = getLineNumber(content, newMatch.index);
|
|
60266
|
+
const lineContent = lines[lineNumber - 1]?.trim() || "";
|
|
60267
|
+
result.findings.push({
|
|
60268
|
+
severity: "blocking",
|
|
60269
|
+
category: "tenant-isolation",
|
|
60270
|
+
message: `new ${entity} { } without TenantId assignment`,
|
|
60271
|
+
file: path23.relative(structure.root, file),
|
|
60272
|
+
line: lineNumber,
|
|
60273
|
+
code: truncateCode(lineContent),
|
|
60274
|
+
suggestion: `Add TenantId = tenantId to the object initializer, or use ${entity}.Create(tenantId, ...) factory method.`,
|
|
60275
|
+
cweId: "CWE-639"
|
|
60276
|
+
});
|
|
60277
|
+
}
|
|
59707
60278
|
}
|
|
59708
60279
|
}
|
|
59709
60280
|
}
|
|
@@ -59829,6 +60400,49 @@ async function checkInputValidation(structure, result) {
|
|
|
59829
60400
|
}
|
|
59830
60401
|
}
|
|
59831
60402
|
}
|
|
60403
|
+
async function checkGuidEmpty(structure, result) {
|
|
60404
|
+
const searchPaths = [];
|
|
60405
|
+
if (structure.application) searchPaths.push(structure.application);
|
|
60406
|
+
if (structure.api) searchPaths.push(structure.api);
|
|
60407
|
+
if (searchPaths.length === 0) return;
|
|
60408
|
+
const guidEmptyPattern = /Guid\.Empty/g;
|
|
60409
|
+
for (const searchPath of searchPaths) {
|
|
60410
|
+
const serviceFiles = await findFiles("**/*Service.cs", { cwd: searchPath });
|
|
60411
|
+
const controllerFiles = await findFiles("**/Controllers/**/*Controller.cs", { cwd: searchPath });
|
|
60412
|
+
const filesToScan = [...serviceFiles, ...controllerFiles];
|
|
60413
|
+
result.stats.filesScanned += filesToScan.length;
|
|
60414
|
+
for (const file of filesToScan) {
|
|
60415
|
+
if (isExcludedFile(file)) continue;
|
|
60416
|
+
const content = await readText(file);
|
|
60417
|
+
const lines = content.split("\n");
|
|
60418
|
+
guidEmptyPattern.lastIndex = 0;
|
|
60419
|
+
let match2;
|
|
60420
|
+
while ((match2 = guidEmptyPattern.exec(content)) !== null) {
|
|
60421
|
+
const lineNumber = getLineNumber(content, match2.index);
|
|
60422
|
+
const lineContent = lines[lineNumber - 1]?.trim() || "";
|
|
60423
|
+
if (lineContent.startsWith("//") || lineContent.startsWith("*") || lineContent.startsWith("/*")) {
|
|
60424
|
+
continue;
|
|
60425
|
+
}
|
|
60426
|
+
if (/[!=]=\s*Guid\.Empty/.test(lineContent) || /Guid\.Empty\s*[!=]=/.test(lineContent)) {
|
|
60427
|
+
continue;
|
|
60428
|
+
}
|
|
60429
|
+
if (/default\s*[:(]/.test(lineContent) || /\?\?\s*Guid\.Empty/.test(lineContent)) {
|
|
60430
|
+
continue;
|
|
60431
|
+
}
|
|
60432
|
+
result.findings.push({
|
|
60433
|
+
severity: "critical",
|
|
60434
|
+
category: "guid-empty",
|
|
60435
|
+
message: "Guid.Empty used as a value in business logic",
|
|
60436
|
+
file: path23.relative(structure.root, file),
|
|
60437
|
+
line: lineNumber,
|
|
60438
|
+
code: truncateCode(lineContent),
|
|
60439
|
+
suggestion: "Replace Guid.Empty with actual value from the current user context (e.g., currentUser.Id, tenantId). Guid.Empty typically indicates a missing resolution.",
|
|
60440
|
+
cweId: "CWE-639"
|
|
60441
|
+
});
|
|
60442
|
+
}
|
|
60443
|
+
}
|
|
60444
|
+
}
|
|
60445
|
+
}
|
|
59832
60446
|
async function getFilesToScan(structure, patterns) {
|
|
59833
60447
|
const allFiles = [];
|
|
59834
60448
|
for (const pattern of patterns) {
|
|
@@ -59971,6 +60585,7 @@ var init_validate_security = __esm({
|
|
|
59971
60585
|
"authorization",
|
|
59972
60586
|
"dangerous-functions",
|
|
59973
60587
|
"input-validation",
|
|
60588
|
+
"guid-empty",
|
|
59974
60589
|
"xss",
|
|
59975
60590
|
"csrf",
|
|
59976
60591
|
"logging-sensitive",
|
|
@@ -61309,6 +61924,90 @@ async function checkSecurity(context) {
|
|
|
61309
61924
|
}
|
|
61310
61925
|
}
|
|
61311
61926
|
}
|
|
61927
|
+
if (file.language === "csharp" && file.relativePath.includes("Service")) {
|
|
61928
|
+
const dbSetPattern = /_context\.(\w+)\./g;
|
|
61929
|
+
let dbSetMatch;
|
|
61930
|
+
dbSetPattern.lastIndex = 0;
|
|
61931
|
+
while ((dbSetMatch = dbSetPattern.exec(file.content)) !== null) {
|
|
61932
|
+
const chainStart = dbSetMatch.index;
|
|
61933
|
+
const semicolonIndex = file.content.indexOf(";", chainStart);
|
|
61934
|
+
if (semicolonIndex === -1) continue;
|
|
61935
|
+
const fullChain = file.content.substring(chainStart, semicolonIndex);
|
|
61936
|
+
if (/\.Add\s*\(/.test(fullChain) || /\.Remove\s*\(/.test(fullChain) || /\.Update\s*\(/.test(fullChain)) continue;
|
|
61937
|
+
if (!fullChain.includes("(")) continue;
|
|
61938
|
+
if (file.content.includes("ITenantEntity") || file.content.includes("TenantId")) {
|
|
61939
|
+
if (!fullChain.includes("TenantId")) {
|
|
61940
|
+
const lineNumber = getLineNumber3(file.content, dbSetMatch.index);
|
|
61941
|
+
const lineContent = lines[lineNumber - 1]?.trim() || "";
|
|
61942
|
+
if (isInComment(lineContent)) continue;
|
|
61943
|
+
findings.push({
|
|
61944
|
+
id: generateFindingId("security"),
|
|
61945
|
+
category: "security",
|
|
61946
|
+
severity: "blocking",
|
|
61947
|
+
title: "Query Without Tenant Filter",
|
|
61948
|
+
description: `Query on _context.${dbSetMatch[1]} does not include TenantId filter. In a multi-tenant application, all queries must filter by tenant.`,
|
|
61949
|
+
file: file.relativePath,
|
|
61950
|
+
line: lineNumber,
|
|
61951
|
+
code: truncateCode2(lineContent),
|
|
61952
|
+
suggestion: "Add .Where(x => x.TenantId == tenantId) or ensure a global query filter is applied.",
|
|
61953
|
+
autoFixable: false,
|
|
61954
|
+
cweId: "CWE-639"
|
|
61955
|
+
});
|
|
61956
|
+
}
|
|
61957
|
+
}
|
|
61958
|
+
}
|
|
61959
|
+
if (file.content.includes("ITenantEntity") || file.content.includes(": TenantEntity")) {
|
|
61960
|
+
const newEntityPattern = /new\s+(\w+)\s*\{([^}]*)\}/gs;
|
|
61961
|
+
let newMatch;
|
|
61962
|
+
newEntityPattern.lastIndex = 0;
|
|
61963
|
+
while ((newMatch = newEntityPattern.exec(file.content)) !== null) {
|
|
61964
|
+
const entityName = newMatch[1];
|
|
61965
|
+
const initializer3 = newMatch[2];
|
|
61966
|
+
if (/Dto|Request|Response|Result|Exception|Options|Config/i.test(entityName)) continue;
|
|
61967
|
+
if (!initializer3.includes("TenantId")) {
|
|
61968
|
+
const lineNumber = getLineNumber3(file.content, newMatch.index);
|
|
61969
|
+
const lineContent = lines[lineNumber - 1]?.trim() || "";
|
|
61970
|
+
if (isInComment(lineContent)) continue;
|
|
61971
|
+
findings.push({
|
|
61972
|
+
id: generateFindingId("security"),
|
|
61973
|
+
category: "security",
|
|
61974
|
+
severity: "critical",
|
|
61975
|
+
title: "Entity Creation Without TenantId",
|
|
61976
|
+
description: `new ${entityName} { } without TenantId assignment. Tenant entities must always have TenantId set.`,
|
|
61977
|
+
file: file.relativePath,
|
|
61978
|
+
line: lineNumber,
|
|
61979
|
+
code: truncateCode2(lineContent),
|
|
61980
|
+
suggestion: `Add TenantId = tenantId to the initializer, or use ${entityName}.Create(tenantId, ...).`,
|
|
61981
|
+
autoFixable: false,
|
|
61982
|
+
cweId: "CWE-639"
|
|
61983
|
+
});
|
|
61984
|
+
}
|
|
61985
|
+
}
|
|
61986
|
+
}
|
|
61987
|
+
const guidEmptyPattern = /Guid\.Empty/g;
|
|
61988
|
+
let guidMatch;
|
|
61989
|
+
guidEmptyPattern.lastIndex = 0;
|
|
61990
|
+
while ((guidMatch = guidEmptyPattern.exec(file.content)) !== null) {
|
|
61991
|
+
const lineNumber = getLineNumber3(file.content, guidMatch.index);
|
|
61992
|
+
const lineContent = lines[lineNumber - 1]?.trim() || "";
|
|
61993
|
+
if (isInComment(lineContent)) continue;
|
|
61994
|
+
if (/[!=]=\s*Guid\.Empty/.test(lineContent) || /Guid\.Empty\s*[!=]=/.test(lineContent)) continue;
|
|
61995
|
+
if (/default\s*[:(]/.test(lineContent) || /\?\?\s*Guid\.Empty/.test(lineContent)) continue;
|
|
61996
|
+
findings.push({
|
|
61997
|
+
id: generateFindingId("security"),
|
|
61998
|
+
category: "security",
|
|
61999
|
+
severity: "critical",
|
|
62000
|
+
title: "Guid.Empty Used as Value",
|
|
62001
|
+
description: "Guid.Empty used as a business value. This typically indicates a missing resolution (e.g., current user ID, tenant ID).",
|
|
62002
|
+
file: file.relativePath,
|
|
62003
|
+
line: lineNumber,
|
|
62004
|
+
code: truncateCode2(lineContent),
|
|
62005
|
+
suggestion: "Replace Guid.Empty with the actual value from user context (currentUser.Id, tenantId, etc.).",
|
|
62006
|
+
autoFixable: false,
|
|
62007
|
+
cweId: "CWE-639"
|
|
62008
|
+
});
|
|
62009
|
+
}
|
|
62010
|
+
}
|
|
61312
62011
|
if (["typescript", "javascript", "tsx", "jsx"].includes(file.language)) {
|
|
61313
62012
|
for (const { pattern, name } of XSS_PATTERNS) {
|
|
61314
62013
|
let match2;
|