Files
gridpilot.gg/apps/website/eslint-rules/ui-element-purity.js
2026-01-14 23:46:04 +01:00

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',
});
}
},
};
},
};