Files
gridpilot.gg/apps/website/eslint-rules/view-model-taxonomy.js
2026-01-23 15:30:23 +01:00

129 lines
4.9 KiB
JavaScript

/**
* 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/display-objects/
*/
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/display-objects/. 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/display-objects/',
];
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/display-objects/') ||
// Also check for patterns like ../contracts/...
importPath.includes('contracts') ||
importPath.includes('view-models') ||
importPath.includes('view-data') ||
importPath.includes('display-objects') ||
// 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',
});
}
},
};
},
};