/** * validate-conventions/execute.ts — Scan a SmartStack project for convention violations. * * Three checks are implemented (ported from SmartStack.cli/src/mcp/tools/validate-conventions.ts): * 1. namespaces — each layer's .cs files should start with the expected namespace prefix * 2. entities — classes inheriting BaseEntity must implement a tenant interface, * have a private ctor, and a Create() factory method * 3. controllers — controllers should use [NavRoute], not a hardcoded [Route] attribute, * and never both at once * * The report returned is a plain JSON object Claude reads from stdout. */ import path from 'node:path'; import { findSmartStackStructure, type SmartStackStructure } from '../../../../lib/detector.js'; import { findFiles, readText } from '../../../../lib/fs.js'; import { findCsprojFiles, detectNamespaces, type NamespaceConfig } from '../../../../lib/dotnet.js'; import { extractNavRoutes } from '../../../../lib/navroute-parser.js'; import type { ValidateConventionsArgs, ValidateConventionsOutput, Finding, Category, ConventionsReport, } from './types.js'; // ─── Entry point ────────────────────────────────────────────────────────── export async function execute( args: ValidateConventionsArgs, ): Promise { const projectPath = args.projectPath || process.cwd(); const structure = await findSmartStackStructure(projectPath); // Determine which checks to run const expanded: Category[] = args.checks.includes('all') ? ['namespaces', 'entities', 'controllers'] : (args.checks.filter((c) => c !== 'all') as Category[]); // Detect namespace config (used by the namespaces check). const csprojFiles = await findCsprojFiles(projectPath); const detectedNs = await detectNamespaces(csprojFiles); const baseNamespace = args.baseNamespace ?? detectedNs?.baseNamespace ?? null; const findings: Finding[] = []; let filesInspected = 0; // Run each check if (expanded.includes('namespaces')) { filesInspected += await checkNamespaces(structure, baseNamespace, findings); } if (expanded.includes('entities')) { filesInspected += await checkEntities(structure, findings); } if (expanded.includes('controllers')) { filesInspected += await checkControllers(structure, findings); } // Build the report const counts = { errors: findings.filter((f) => f.severity === 'error').length, warnings: findings.filter((f) => f.severity === 'warning').length, infos: findings.filter((f) => f.severity === 'info').length, }; const byCategory: Record = { namespaces: 0, entities: 0, controllers: 0, }; for (const finding of findings) { byCategory[finding.category]++; } const report: ConventionsReport = { projectPath, structure: { domain: structure.domain ?? null, application: structure.application ?? null, infrastructure: structure.infrastructure ?? null, api: structure.api ?? null, web: structure.web ?? null, }, checksRun: expanded, filesInspected, findings, counts, byCategory, }; const valid = counts.errors === 0; const summary = valid ? `All checks passed (${filesInspected} files inspected, ${counts.warnings} warnings, ${counts.infos} infos)` : `${counts.errors} error(s), ${counts.warnings} warning(s), ${counts.infos} info(s) across ${filesInspected} files`; const nextSteps: string[] = []; if (counts.errors > 0) { nextSteps.push('Fix the "error"-severity findings before proceeding — they break conventions that are enforced at runtime'); } if (counts.warnings > 0) { nextSteps.push('Review the warnings — they indicate drift from SmartStack conventions that may cause issues later'); } if (valid && counts.warnings === 0) { nextSteps.push('No action needed — the project fully complies with SmartStack conventions'); } return { success: true, command: 'validate-conventions', data: { valid, summary }, report, errors: [], warnings: [], nextSteps, }; } // ─── Check 1: namespaces ────────────────────────────────────────────────── async function checkNamespaces( structure: SmartStackStructure, baseNamespace: string | null, findings: Finding[], ): Promise { if (!baseNamespace) { findings.push({ severity: 'warning', category: 'namespaces', message: 'Could not detect a base namespace from .csproj files — skipping namespace checks', suggestion: 'Pass --base-namespace if the project does not follow the *.Domain/*.Application/*.Infrastructure/*.Api layout', }); return 0; } const expectedByLayer: Array<{ path: string | undefined; expected: string; layer: string; }> = [ { path: structure.domain, expected: `${baseNamespace}.Domain`, layer: 'Domain' }, { path: structure.application, expected: `${baseNamespace}.Application`, layer: 'Application' }, { path: structure.infrastructure, expected: `${baseNamespace}.Infrastructure`, layer: 'Infrastructure' }, { path: structure.api, expected: `${baseNamespace}.Api`, layer: 'Api' }, ]; let inspected = 0; for (const layer of expectedByLayer) { if (!layer.path) continue; const csFiles = await findFiles('**/*.cs', { cwd: layer.path }); // Inspect at most 30 files per layer to keep the audit fast. const sample = csFiles.slice(0, 30); inspected += sample.length; for (const file of sample) { const content = await readText(file); const match = content.match(/namespace\s+([\w.]+)/); if (!match) continue; const ns = match[1]; if (!ns.startsWith(layer.expected)) { // Downgrade to warning if the namespace still follows the Clean Architecture // pattern ({Base}.{Layer}) — common for client extension projects. const followsPattern = ['Domain', 'Application', 'Infrastructure', 'Api'].some( (l) => ns.includes(`.${l}`) || ns.endsWith(`.${l}`), ); findings.push({ severity: followsPattern ? 'warning' : 'error', category: 'namespaces', message: `${layer.layer} file has namespace "${ns}" (expected prefix: "${layer.expected}")`, file: path.relative(structure.root, file), suggestion: followsPattern ? `Namespace follows Clean Architecture pattern but differs from the detected base "${baseNamespace}". Expected to start with "${layer.expected}".` : `Should start with "${layer.expected}"`, }); } } } return inspected; } // ─── Check 2: entities ──────────────────────────────────────────────────── async function checkEntities( structure: SmartStackStructure, findings: Finding[], ): Promise { if (!structure.domain) { findings.push({ severity: 'warning', category: 'entities', message: 'Domain project not found — skipping entity validation', }); return 0; } const csFiles = await findFiles('**/*.cs', { cwd: structure.domain }); let inspected = 0; for (const file of csFiles) { const fileName = path.basename(file, '.cs'); // Skip obvious non-entity files if ( fileName.endsWith('Dto') || fileName.endsWith('Command') || fileName.endsWith('Query') || fileName.endsWith('Handler') || fileName.endsWith('Validator') || fileName.endsWith('Exception') || fileName.startsWith('I') ) { continue; } const content = await readText(file); const classMatch = content.match( /public\s+(?:class|record)\s+(\w+)(?:\s*:\s*([^{]+))?/, ); if (!classMatch) continue; inspected++; const entityName = classMatch[1]; const inheritance = classMatch[2]?.trim() || ''; const hasBaseEntity = inheritance.includes('BaseEntity'); const hasSystemEntity = inheritance.includes('SystemEntity'); if (!hasBaseEntity && !hasSystemEntity) continue; // value object / other const hasTenantInterface = inheritance.includes('ITenantEntity') || inheritance.includes('IOptionalTenantEntity') || inheritance.includes('IScopedTenantEntity'); if (hasBaseEntity && !hasSystemEntity && !hasTenantInterface) { findings.push({ severity: 'warning', category: 'entities', message: `Entity "${entityName}" inherits BaseEntity but does not implement any tenant interface`, file: path.relative(structure.root, file), suggestion: 'Add ITenantEntity (strict), IOptionalTenantEntity (cross-tenant), or IScopedTenantEntity (with scope)', }); } if (!content.includes(`private ${entityName}()`)) { findings.push({ severity: 'warning', category: 'entities', message: `Entity "${entityName}" is missing a private constructor (required by EF Core)`, file: path.relative(structure.root, file), suggestion: `Add: private ${entityName}() { }`, }); } if (!content.includes(`public static ${entityName} Create(`)) { findings.push({ severity: 'warning', category: 'entities', message: `Entity "${entityName}" is missing a factory method`, file: path.relative(structure.root, file), suggestion: `Add: public static ${entityName} Create(...)`, }); } } return inspected; } // ─── Check 3: controllers ───────────────────────────────────────────────── async function checkControllers( structure: SmartStackStructure, findings: Finding[], ): Promise { if (!structure.api) { findings.push({ severity: 'warning', category: 'controllers', message: 'API project not found — skipping controller route validation', }); return 0; } // System controllers that legitimately use hardcoded [Route] — whitelisted. // Keep this list short; controllers not in the navigation hierarchy should // migrate to [NavRoute] when possible. const SYSTEM_CONTROLLERS = new Set([ 'AuthController', 'RegistrationController', 'OnboardingController', 'NavigationController', 'LogsController', 'EntraController', 'PreferencesController', 'ApplicationTrackingController', 'NotificationsController', 'DashboardController', ]); const controllerFiles = await findFiles('**/Controllers/**/*Controller.cs', { cwd: structure.api, }); let inspected = 0; for (const file of controllerFiles) { const fileName = path.basename(file, '.cs'); if (SYSTEM_CONTROLLERS.has(fileName)) continue; inspected++; const content = await readText(file); const hasNavRoute = content.includes('[NavRoute('); const routeMatch = content.match(/\[Route\s*\(\s*"([^"]+)"\s*\)\]/); if (hasNavRoute && routeMatch) { findings.push({ severity: 'error', category: 'controllers', message: `Controller "${fileName}" has BOTH [NavRoute] and [Route("${routeMatch[1]}")]. Only [NavRoute] should be used.`, file: path.relative(structure.root, file), suggestion: `Remove [Route("${routeMatch[1]}")] — NavRoute resolves the path dynamically from the navigation database at startup`, }); } if (hasNavRoute) { // Validate the structure of every [NavRoute(...)] attribute. const parsed = extractNavRoutes(content); for (const nr of parsed) { const parts = nr.navRoute.split('.'); if (parts.length < 2) { findings.push({ severity: 'error', category: 'controllers', message: `Controller "${fileName}" has a NavRoute with insufficient depth: "${nr.navRoute}"`, file: path.relative(structure.root, file), suggestion: 'NavRoute must have at least 2 segments: "application.module"', }); } if (parts.some((p) => p !== p.toLowerCase())) { findings.push({ severity: 'error', category: 'controllers', message: `Controller "${fileName}" has a NavRoute with uppercase characters: "${nr.navRoute}"`, file: path.relative(structure.root, file), suggestion: 'NavRoute segments must be lowercase (e.g., "administration.users")', }); } } } else if (routeMatch) { // Hardcoded route without any NavRoute — flag as warning. findings.push({ severity: 'warning', category: 'controllers', message: `Controller "${fileName}" uses hardcoded [Route("${routeMatch[1]}")] instead of [NavRoute]`, file: path.relative(structure.root, file), suggestion: 'Migrate to [NavRoute("module.section")] so the URL is driven by the navigation database. Alternatively, whitelist the controller if it is truly system-level.', }); } } return inspected; }