/** * ESLint rule to enforce ViewModel architectural boundaries * * ViewModels in lib/view-models/ must: * 1. NOT contain the word "DTO" (DTOs are for API/Services) * 2. NOT define ViewData interfaces (ViewData must be in lib/view-data/) * 3. NOT import from DTO paths (DTOs belong to lib/types/generated/) * 4. ONLY import from allowed paths: lib/contracts/, lib/view-models/, lib/view-data/, lib/formatters/ */ module.exports = { meta: { type: 'problem', docs: { description: 'Enforce ViewModel architectural boundaries', category: 'Architecture', recommended: true, }, fixable: null, schema: [], messages: { noDtoInViewModel: 'ViewModels must not use the word "DTO". DTOs belong to the API/Service layer. Use plain logic-rich properties or ViewData types.', noDtoImport: 'ViewModels must not import from DTO paths. DTOs belong to lib/types/generated/. Import from lib/view-data/ or use plain properties instead.', noViewDataDefinition: 'ViewData must not be defined within ViewModel files. Import them from lib/view-data/ instead.', strictImport: 'ViewModels can only import from lib/contracts/, lib/view-models/, lib/view-data/, or lib/formatters/. External imports are allowed. Found: {{importPath}}', }, }, create(context) { const filename = context.getFilename(); const isInViewModels = filename.includes('/lib/view-models/'); if (!isInViewModels) return {}; return { // Check for "DTO" in any identifier (variable, class, interface, property) // Only catch identifiers that end with "DTO" or are exactly "DTO" // This avoids false positives like "formattedTotalSpent" which contains "DTO" as a substring Identifier(node) { const name = node.name.toUpperCase(); // Only catch identifiers that end with "DTO" or are exactly "DTO" if (name === 'DTO' || name.endsWith('DTO')) { context.report({ node, messageId: 'noDtoInViewModel', }); } }, // Check for imports from DTO paths and enforce strict import rules ImportDeclaration(node) { const importPath = node.source.value; // Check 1: Disallowed paths (DTO and service layers) // This catches ANY import from these paths, regardless of name if (importPath.includes('/lib/types/generated/') || importPath.includes('/lib/dtos/') || importPath.includes('/lib/api/') || importPath.includes('/lib/services/')) { context.report({ node, messageId: 'noDtoImport', }); } // Check 2: Strict import path enforcement // Only allow imports from these specific paths const allowedPaths = [ '@/lib/contracts/', '@/lib/view-models/', '@/lib/view-data/', '@/lib/formatters/', ]; const isAllowed = allowedPaths.some(path => importPath.startsWith(path)); const isRelativeImport = importPath.startsWith('.'); const isExternal = !importPath.startsWith('.') && !importPath.startsWith('@'); // For relative imports, check if they contain allowed patterns // This is a heuristic - may need refinement based on project structure const isRelativeAllowed = isRelativeImport && ( importPath.includes('/lib/contracts/') || importPath.includes('/lib/view-models/') || importPath.includes('/lib/view-data/') || importPath.includes('/lib/formatters/') || // Also check for patterns like ../contracts/... importPath.includes('contracts') || importPath.includes('view-models') || importPath.includes('view-data') || importPath.includes('formatters') || // Allow relative imports to view models (e.g., ./InvoiceViewModel, ../ViewModelName) // This matches patterns like ./ViewModelName or ../ViewModelName /^\.\/[A-Z][a-zA-Z0-9]*ViewModel$/.test(importPath) || /^\.\.\/[A-Z][a-zA-Z0-9]*ViewModel$/.test(importPath) ); // Report if it's an internal import that's not allowed if (!isAllowed && !isRelativeAllowed && !isExternal) { context.report({ node, messageId: 'strictImport', data: { importPath }, }); } }, // Check for ViewData definitions (Interface or Type Alias) TSInterfaceDeclaration(node) { if (node.id && node.id.name && node.id.name.endsWith('ViewData')) { context.report({ node, messageId: 'noViewDataDefinition', }); } }, TSTypeAliasDeclaration(node) { if (node.id && node.id.name && node.id.name.endsWith('ViewData')) { context.report({ node, messageId: 'noViewDataDefinition', }); } }, }; }, };