/** * ESLint rule to enforce UI element purity and architectural boundaries * * UI elements in ui/ must be: * - Stateless (no useState, useReducer) * - No side effects (no useEffect) * - Pure functions that only render based on props * - Isolated (cannot import from outside the ui/ directory) * * Rationale: * - ui/ is for reusable, pure presentation elements * - Isolation ensures UI elements don't depend on app-specific logic * - Raw HTML and className are allowed in ui/ for implementation */ module.exports = { meta: { type: 'problem', docs: { description: 'Enforce UI elements are pure and isolated', category: 'Architecture', recommended: true, }, fixable: null, schema: [], messages: { noStateInUi: 'UI elements in ui/ must be stateless. Use components/ for stateful wrappers.', noHooksInUi: 'UI elements must not use hooks. Use components/ or hooks/ for stateful logic.', noSideEffects: 'UI elements must not have side effects. Use components/ for side effect logic.', noExternalImports: 'UI elements in ui/ cannot import from outside the ui/ directory. Only npm packages and other UI elements are allowed.', }, }, create(context) { const filename = context.getFilename(); const isInUi = filename.includes('/ui/'); if (!isInUi) return {}; return { // Check for imports from outside ui/ ImportDeclaration(node) { const importPath = node.source.value; // Allow npm packages (don't start with . or @/) if (!importPath.startsWith('.') && !importPath.startsWith('@/')) { return; } // Check internal imports const isInternalUiImport = importPath.startsWith('@/ui/') || (importPath.startsWith('.') && !importPath.includes('..')); // Special case for relative imports that stay within ui/ let staysInUi = false; if (importPath.startsWith('.')) { const path = require('path'); const absoluteImportPath = path.resolve(path.dirname(filename), importPath); const uiDir = filename.split('/ui/')[0] + '/ui'; if (absoluteImportPath.startsWith(uiDir)) { staysInUi = true; } } if (!isInternalUiImport && !staysInUi) { context.report({ node, messageId: 'noExternalImports', }); } }, // Check for 'use client' directive ExpressionStatement(node) { if (node.expression.type === 'Literal' && node.expression.value === 'use client') { context.report({ node, messageId: 'noStateInUi', }); } }, // Check for state hooks CallExpression(node) { if (node.callee.type !== 'Identifier') return; const hookName = node.callee.name; // State management hooks if (['useState', 'useReducer', 'useRef'].includes(hookName)) { context.report({ node, messageId: 'noStateInUi', }); } // Effect hooks if (['useEffect', 'useLayoutEffect', 'useInsertionEffect'].includes(hookName)) { context.report({ node, messageId: 'noSideEffects', }); } // Context (can introduce state) if (hookName === 'useContext') { context.report({ node, messageId: 'noStateInUi', }); } }, }; }, };