117 lines
3.5 KiB
JavaScript
117 lines
3.5 KiB
JavaScript
/**
|
|
* 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',
|
|
});
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|