Дизайн-системы решают проблему консистентности UI, но только на уровне компонентов. Что делать, если разработчики используют компоненты неправильно? Импортируют напрямую из внутренних модулей, применяют хардкод-цвета вместо токенов, нарушают архитектурные слои?
Статический анализ кода — единственный способ автоматизировать эти проверки. В этой статье я покажу, пошагово и с примерами, как создавать собственные ESLint правила, которые превращают дизайн-систему из набора компонентов в самодокументируемую архитектуру с автоматическим контролем.
Зачем нужны кастомные правила
Проблемы, которые не решают готовые плагины
Стандартные ESLint правила (даже из eslint-plugin-import или @typescript-eslint) не знают о специфике вашей дизайн-системы:
// ❌ Разработчик импортирует из внутреннего модуля
import { Button } from '@design-system/ui/button/Button'
// ✅ Должен использовать barrel export
import { Button } from '@design-system/ui'
// ❌ Использует хардкод-цвет вместо токена
<Box sx={{ color: '#FF0000' }} />
// ✅ Должен использовать токен из темы
<Box sx={{ color: 'error.main' }} />
// ❌ Импортирует из верхнего слоя в FSD
import { UserWidget } from '@/widgets/user' // из features/
// ✅ Должен соблюдать направление зависимостей
import { UserCard } from '@/entities/user' // правильноЭти проблемы архитектурные, и для их решения нужны специализированные правила.
В проекте potapov.me реализованы FSD-правила, запрещающие deep imports и нарушения слоев. Это позволило поддерживать архитектуру на 100+ компонентах без code review на каждом MR.
Как работает ESLint: основы
Перед тем как писать правила, нужно понять, как ESLint анализирует код.
Жизненный цикл проверки
Процесс пошагово:
- Parser (например,
@typescript-eslint/parser) читает ваш код - Преобразует его в AST (Abstract Syntax Tree) — дерево узлов
- ESLint проходит по дереву и вызывает ваши правила для каждого узла
- Правила проверяют узлы и сообщают об ошибках
- Опционально: правила предлагают автофиксы
Что такое AST (на простом примере)
Возьмем простой код:
const color = "#FF0000";ESLint парсит его в дерево:
{
"type": "VariableDeclaration",
"kind": "const",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "color"
},
"init": {
"type": "Literal",
"value": "#FF0000",
"raw": "'#FF0000'"
}
}
]
}Как это читать:
- Корневой узел:
VariableDeclaration(объявление переменной) - Внутри: массив
declarations(может бытьconst a = 1, b = 2) - Каждый элемент:
VariableDeclaratorс именем (id) и значением (init) - Значение:
Literalс типом string и значением#FF0000
Инструменты для изучения AST: - astexplorer.net — вставьте свой код и увидите дерево - ts-ast-viewer.com — для TypeScript/TSX
Анатомия ESLint правила
Каждое правило — это объект с двумя частями:
export const myRule = {
// 1. МЕТА-ИНФОРМАЦИЯ
meta: {
type: "problem", // Тип: problem/suggestion/layout
docs: {
description: "Что проверяет правило",
recommended: "error", // Рекомендуемый уровень
},
messages: {
// Шаблоны сообщений об ошибках
myError: "Найдена проблема: {{ details }}",
},
fixable: "code", // Есть ли автофикс
schema: [], // Опции правила (JSON Schema)
},
// 2. ЛОГИКА ПРОВЕРКИ
create(context) {
return {
// Селекторы узлов AST
Literal(node) {
// Вызывается для КАЖДОГО литерала в коде
// Здесь ваша логика проверки
},
JSXAttribute(node) {
// Вызывается для КАЖДОГО атрибута JSX
},
};
},
};Как это работает:
- ESLint проходит по AST дереву
- Для каждого узла типа
Literalвызывает функциюLiteral(node) - Внутри функции вы проверяете узел
- Если находите проблему — вызываете
context.report()
Быстрый старт: первое правило за 5 минут
Начнем с самого простого примера — запретим использование console.log в production коде.
Шаг 1: Создаем файл правила
mkdir -p eslint-rules
touch eslint-rules/no-console-log.jsШаг 2: Пишем правило
// eslint-rules/no-console-log.js
module.exports = {
meta: {
type: "problem",
docs: {
description: "Запрещает console.log в коде",
},
messages: {
noConsoleLog: "Используйте logger вместо console.log",
},
fixable: "code", // Будем автоматически исправлять
},
create(context) {
return {
// Ищем вызовы функций: foo(), bar.baz()
CallExpression(node) {
// Проверяем: это console.log?
const isConsoleLog =
node.callee.type === "MemberExpression" && // foo.bar
node.callee.object.name === "console" && // console
node.callee.property.name === "log"; // .log
if (isConsoleLog) {
context.report({
node,
messageId: "noConsoleLog",
fix(fixer) {
// Заменяем console на logger
return fixer.replaceText(node.callee.object, "logger");
},
});
}
},
};
},
};Разбор кода:
CallExpression— узел для любого вызова функцииnode.callee— то, что вызывается (вfoo.bar()этоfoo.bar)MemberExpression— доступ к свойству объекта (console.log)node.callee.object.name— имя объекта (console)node.callee.property.name— имя свойства (log)
Шаг 3: Подключаем правило
// .eslintrc.js
module.exports = {
rules: {
"no-console-log": require("./eslint-rules/no-console-log"),
},
};Шаг 4: Тестируем
// test.js
console.log("Hello"); // ❌ ESLint ошибка: Используйте logger вместо console.log
// Запускаем автофикс:
// eslint test.js --fix
// Результат:
logger.log("Hello"); // ✅ Исправлено автоматическиПоздравляю! Вы написали первое ESLint правило.
Практика: правила для дизайн-системы
Теперь перейдем к более сложным примерам для дизайн-систем.
Пример 1: Запрет хардкод-цветов (пошагово)
Цель: Запретить #FF0000, заставить использовать theme.colors.error
Шаг 1: Анализируем AST
Откроем astexplorer.net и вставим код:
<Box color="#FF0000" />Смотрим на дерево:
{
"type": "JSXElement",
"openingElement": {
"type": "JSXOpeningElement",
"attributes": [
{
"type": "JSXAttribute",
"name": { "type": "JSXIdentifier", "name": "color" },
"value": {
"type": "Literal",
"value": "#FF0000"
}
}
]
}
}Вывод: Нужно проверять узлы типа JSXAttribute, у которых value.type === 'Literal' и значение похоже на цвет.
Шаг 2: Пишем детектор цветов
// helpers/color-detector.ts
// Паттерны для разных форматов цветов
const COLOR_PATTERNS = {
hex3: /^#[0-9A-Fa-f]{3}$/, // #F00
hex6: /^#[0-9A-Fa-f]{6}$/, // #FF0000
hex8: /^#[0-9A-Fa-f]{8}$/, // #FF0000FF (с alpha)
rgb: /^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+/, // rgb(255, 0, 0)
hsl: /^hsla?\(\s*\d+\s*,\s*\d+%?\s*,/, // hsl(0, 100%, 50%)
};
/**
* Проверяет, является ли строка цветом
*/
export function isColorValue(value: string): boolean {
if (typeof value !== "string") return false;
return Object.values(COLOR_PATTERNS).some((pattern) =>
pattern.test(value.trim())
);
}
// Примеры использования:
isColorValue("#FF0000"); // → true
isColorValue("rgb(255, 0, 0)"); // → true
isColorValue("primary"); // → false
isColorValue("16px"); // → falseШаг 3: Мапим цвета на токены
// helpers/color-tokens.ts
/**
* Мапинг популярных цветов на токены темы
*/
const COLOR_TO_TOKEN: Record<string, string> = {
"#FF0000": "error",
"#F00": "error",
"rgb(255, 0, 0)": "error",
"#00FF00": "success",
"#0F0": "success",
"#0000FF": "primary",
"#00F": "primary",
"#FFA500": "warning",
};
/**
* Предлагает токен для цвета
*/
export function suggestToken(color: string): string | null {
// Нормализуем: убираем пробелы, приводим к верхнему регистру
const normalized = color.replace(/\s/g, "").toUpperCase();
return COLOR_TO_TOKEN[normalized] || null;
}
// Примеры:
suggestToken("#FF0000"); // → 'error'
suggestToken("rgb(255, 0, 0)"); // → 'error'
suggestToken("#123456"); // → null (неизвестный цвет)Шаг 4: Собираем правило
// rules/no-hardcoded-colors.ts
import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
import { isColorValue } from "../helpers/color-detector";
import { suggestToken } from "../helpers/color-tokens";
type MessageIds = "hardcodedColor" | "useToken";
export const rule = ESLintUtils.RuleCreator(
(name) => `https://docs.mycompany.com/rules/${name}`
)<[], MessageIds>({
name: "no-hardcoded-colors",
meta: {
type: "problem",
docs: {
description:
"Запрещает хардкод-цвета, требует использование токенов темы",
recommended: "error",
},
messages: {
hardcodedColor: "Найден хардкод-цвет: {{ value }}",
useToken: "Используйте токен: theme.colors.{{ token }}",
},
schema: [],
hasSuggestions: true, // Включаем suggestions
},
defaultOptions: [],
create(context) {
return {
/**
* Проверяем JSX атрибуты: <Box color="#FF0000" />
*/
JSXAttribute(node: TSESTree.JSXAttribute) {
// Проверяем, что значение атрибута — строковый литерал
if (node.value?.type !== "Literal") return;
if (typeof node.value.value !== "string") return;
const colorValue = node.value.value;
// Это цвет?
if (!isColorValue(colorValue)) return;
// Нашли хардкод-цвет! Репортим
const token = suggestToken(colorValue);
context.report({
node: node.value,
messageId: "hardcodedColor",
data: { value: colorValue },
// Если знаем токен — предлагаем автофикс
suggest: token
? [
{
messageId: "useToken",
data: { token },
fix(fixer) {
// Заменяем "#FF0000" на {theme.colors.error}
return fixer.replaceText(
node.value!,
`{theme.colors.${token}}`
);
},
},
]
: undefined,
});
},
/**
* Проверяем объекты стилей: sx={{ color: '#FF0000' }}
*/
Property(node: TSESTree.Property) {
// Проверяем значение свойства
if (node.value.type !== "Literal") return;
if (typeof node.value.value !== "string") return;
const colorValue = node.value.value;
if (!isColorValue(colorValue)) return;
// Репортим (без автофикса, т.к. синтаксис может быть разным)
context.report({
node: node.value,
messageId: "hardcodedColor",
data: { value: colorValue },
});
},
/**
* Проверяем CSS-in-JS: styled.div`color: #FF0000;`
*/
TemplateElement(node: TSESTree.TemplateElement) {
const cssText = node.value.raw;
// Ищем все hex-цвета в CSS
const hexColors = cssText.match(/#[0-9A-Fa-f]{3,8}\b/g);
if (hexColors) {
hexColors.forEach((color) => {
context.report({
node,
messageId: "hardcodedColor",
data: { value: color },
});
});
}
},
};
},
});
export default rule;Как это работает:
- JSXAttribute — ловим
<Box color="#FF0000" /> - Property — ловим
sx={{ color: '#FF0000' }} - TemplateElement — ловим
styled`color: #FF0000`
Для каждого случая:
- Проверяем, что значение — строка
- Проверяем, что это цвет (через regex)
- Если цвет известный — предлагаем токен
- Репортим ошибку с автофиксом
Шаг 5: Тестируем правило
// rules/__tests__/no-hardcoded-colors.test.ts
import { RuleTester } from "@typescript-eslint/rule-tester";
import rule from "../no-hardcoded-colors";
const ruleTester = new RuleTester({
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 2020,
sourceType: "module",
ecmaFeatures: { jsx: true },
},
});
ruleTester.run("no-hardcoded-colors", rule, {
valid: [
// ✅ Токены темы
"<Box color={theme.colors.primary} />",
'<Box sx={{ color: "primary.main" }} />',
// ✅ CSS переменные
'const styles = { color: "var(--primary)" }',
// ✅ Ключевые слова CSS
'<Box color="transparent" />',
'<Box color="inherit" />',
// ✅ Не-цвета
'<Box width="100px" />',
'<Button variant="primary">Click</Button>',
],
invalid: [
// ❌ Hex цвета
{
code: '<Box color="#FF0000" />',
errors: [{ messageId: "hardcodedColor" }],
},
{
code: '<Box color="#F00" />',
errors: [{ messageId: "hardcodedColor" }],
},
// ❌ RGB
{
code: '<Box sx={{ backgroundColor: "rgb(255, 0, 0)" }} />',
errors: [{ messageId: "hardcodedColor" }],
},
// ❌ CSS-in-JS
{
code: `
const Button = styled.button\`
color: #FF0000;
background: #00FF00;
\`;
`,
errors: [
{ messageId: "hardcodedColor" },
{ messageId: "hardcodedColor" },
],
},
],
});Запуск тестов:
npm test
# Результат:
# ✓ no-hardcoded-colors
# ✓ valid (6)
# ✓ invalid (4)Пример 2: Контроль Public API (FSD)
Цель: Запретить import { Button } from '@/shared/ui/button/Button', требовать import { Button } from '@/shared/ui'
Как это работает: теория
В Feature-Sliced Design каждый модуль должен экспортировать Public API через index.ts:
shared/
ui/
index.ts ← Public API (barrel export)
button/
Button.tsx
button.css
// ❌ Deep import — импорт из внутренних файлов
import { Button } from "@/shared/ui/button/Button";
// ✅ Public API — импорт через barrel export
import { Button } from "@/shared/ui";Зачем это нужно:
- Инкапсуляция — можем менять внутреннюю структуру без breaking changes
- Контроль — явно указываем, что публичное, что приватное
- Переиспользование — один импорт для всех компонентов модуля
Шаг 1: Определяем deep import
// helpers/fsd-detector.ts
/**
* Проверяет, является ли импорт "глубоким"
*
* @example
* isDeepImport('@/shared/ui/button/Button') → true
* isDeepImport('@/shared/ui') → false
*/
export function isDeepImport(importPath: string): boolean {
// Паттерн: @/layer/slice/segment/...
// Где segment может быть: ui, model, api, lib
const deepImportPattern = /^@\/\w+\/\w+\/\w+\/.+/;
return deepImportPattern.test(importPath);
}
/**
* Извлекает Public API из deep import
*
* @example
* getPublicApi('@/shared/ui/button/Button') → '@/shared/ui'
* getPublicApi('@/entities/user/model/types') → '@/entities/user'
*/
export function getPublicApi(importPath: string): string {
// Убираем всё после третьего слэша
const match = importPath.match(/^(@\/\w+\/\w+)/);
return match ? match[1] : importPath;
}Как работает regex:
@/shared/ui/button/Button
│ │ │ │ │
│ └─┬────┘ └──┬───┘
│ layer segment
└ alias
^@\/\w+\/\w+\/\w+\/.+
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ └─ Есть ещё что-то → deep import
│ │ │ │ │ │ └─ слово (ui, model, etc)
│ │ │ │ │ └─ слэш
│ │ │ │ └─ слово (название слайса)
│ │ │ └─ слэш
│ │ └─ слово (название слоя)
│ └─ слэш
└─ начало строки с @
Шаг 2: Пишем правило
// rules/no-deep-imports.ts
import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
import { isDeepImport, getPublicApi } from "../helpers/fsd-detector";
type MessageIds = "deepImport";
export const rule = ESLintUtils.RuleCreator(
(name) => `https://docs.fsd.dev/rules/${name}`
)<[], MessageIds>({
name: "no-deep-imports",
meta: {
type: "problem",
docs: {
description: "Требует использование Public API (barrel exports)",
recommended: "error",
},
messages: {
deepImport:
"Запрещен deep import. Используйте Public API: {{ publicApi }}",
},
schema: [],
fixable: "code", // Можем автоматически исправить
},
defaultOptions: [],
create(context) {
return {
/**
* Проверяем каждый import в файле
*/
ImportDeclaration(node: TSESTree.ImportDeclaration) {
const importPath = node.source.value;
// Это deep import?
if (!isDeepImport(importPath)) return;
// Вычисляем правильный Public API
const publicApi = getPublicApi(importPath);
context.report({
node: node.source,
messageId: "deepImport",
data: { publicApi },
fix(fixer) {
// Автоматически заменяем путь
return fixer.replaceText(node.source, `'${publicApi}'`);
},
});
},
};
},
});
export default rule;Как работает автофикс:
// ДО автофикса
import { Button } from "@/shared/ui/button/Button";
// ПОСЛЕ автофикса (eslint --fix)
import { Button } from "@/shared/ui";Шаг 3: Расширяем — проверка иерархии слоев
FSD имеет строгую иерархию: верхние слои могут импортировать из нижних, но не наоборот.
app ← может импортировать всё ниже
pages
widgets
features
entities
shared ← ничего не импортирует (кроме библиотек)
// helpers/fsd-layers.ts
const LAYERS = ["shared", "entities", "features", "widgets", "pages", "app"];
/**
* Извлекает слой из пути импорта
*/
export function extractLayer(importPath: string): string | null {
for (const layer of LAYERS) {
if (importPath.includes(`/${layer}/`)) {
return layer;
}
}
return null;
}
/**
* Проверяет, разрешен ли импорт из toLayer в fromLayer
*/
export function canImport(fromLayer: string, toLayer: string): boolean {
const fromIndex = LAYERS.indexOf(fromLayer);
const toIndex = LAYERS.indexOf(toLayer);
// Можно импортировать только из нижних слоев (toIndex <= fromIndex)
return toIndex <= fromIndex;
}Примеры:
canImport("widgets", "entities"); // → true (widgets выше)
canImport("entities", "widgets"); // → false (нельзя вверх!)
canImport("features", "shared"); // → trueДобавляем проверку в правило:
create(context) {
return {
ImportDeclaration(node: TSESTree.ImportDeclaration) {
const importPath = node.source.value;
const currentFile = context.getFilename();
// 1. Проверяем deep imports
if (isDeepImport(importPath)) {
// ... код из предыдущего примера
}
// 2. Проверяем иерархию слоев
const fromLayer = extractLayer(currentFile);
const toLayer = extractLayer(importPath);
if (fromLayer && toLayer && !canImport(fromLayer, toLayer)) {
context.report({
node: node.source,
messageId: 'layerViolation',
data: { from: fromLayer, to: toLayer },
});
}
}
};
}Пример 3: Валидация пропсов компонентов
Цель: Проверять, что компоненты используются с правильными пропсами.
Конфигурация правила
Создадим конфигурационный файл с правилами для каждого компонента:
// configs/component-rules.ts
export const COMPONENT_RULES = {
Button: {
// Обязательные пропсы
requiredProps: ["children"],
// Устаревшие пропсы
deprecatedProps: {
type: "Используйте variant вместо type",
color: 'Используйте variant="primary|secondary|error"',
},
// Взаимоисключающие пропсы
mutuallyExclusive: [
["href", "onClick"], // Либо ссылка, либо кнопка
["loading", "disabled"], // Не нужно disabled если loading
],
},
Input: {
requiredProps: ["label", "name"],
deprecatedProps: {
placeholder: "Используйте label для accessibility",
},
},
IconButton: {
// Требуем aria-label если нет children
conditionalRequired: {
"aria-label": (props) => !props.children,
},
},
};Реализация правила
// rules/valid-component-props.ts
import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
import { COMPONENT_RULES } from "../configs/component-rules";
type MessageIds = "missingRequired" | "deprecated" | "mutuallyExclusive";
export const rule = ESLintUtils.RuleCreator(
(name) => `https://docs.mycompany.com/rules/${name}`
)<[], MessageIds>({
name: "valid-component-props",
meta: {
type: "problem",
docs: {
description: "Валидирует пропсы компонентов дизайн-системы",
recommended: "error",
},
messages: {
missingRequired: "Компонент {{ component }} требует проп: {{ prop }}",
deprecated: "Проп {{ prop }} устарел. {{ message }}",
mutuallyExclusive: "Пропсы {{ props }} взаимоисключающие",
},
schema: [],
},
defaultOptions: [],
create(context) {
/**
* Извлекает имя компонента из JSX узла
*/
function getComponentName(node: TSESTree.JSXOpeningElement): string | null {
if (node.name.type === "JSXIdentifier") {
return node.name.name;
}
// Для <Foo.Bar> возвращаем "Foo.Bar"
if (node.name.type === "JSXMemberExpression") {
return `${node.name.object.name}.${node.name.property.name}`;
}
return null;
}
/**
* Извлекает список пропсов компонента
*/
function getPropNames(node: TSESTree.JSXOpeningElement): string[] {
return node.attributes
.filter(
(attr): attr is TSESTree.JSXAttribute => attr.type === "JSXAttribute"
)
.map((attr) =>
attr.name.type === "JSXIdentifier" ? attr.name.name : ""
)
.filter(Boolean);
}
return {
JSXOpeningElement(node: TSESTree.JSXOpeningElement) {
const componentName = getComponentName(node);
if (!componentName) return;
const rules = COMPONENT_RULES[componentName];
if (!rules) return; // Нет правил для этого компонента
const providedProps = getPropNames(node);
// 1. Проверяем обязательные пропсы
if (rules.requiredProps) {
for (const requiredProp of rules.requiredProps) {
if (!providedProps.includes(requiredProp)) {
context.report({
node,
messageId: "missingRequired",
data: {
component: componentName,
prop: requiredProp,
},
});
}
}
}
// 2. Проверяем deprecated пропсы
if (rules.deprecatedProps) {
for (const attr of node.attributes) {
if (
attr.type === "JSXAttribute" &&
attr.name.type === "JSXIdentifier"
) {
const propName = attr.name.name;
const message = rules.deprecatedProps[propName];
if (message) {
context.report({
node: attr,
messageId: "deprecated",
data: { prop: propName, message },
});
}
}
}
}
// 3. Проверяем взаимоисключающие пропсы
if (rules.mutuallyExclusive) {
for (const group of rules.mutuallyExclusive) {
const found = group.filter((prop) => providedProps.includes(prop));
if (found.length > 1) {
context.report({
node,
messageId: "mutuallyExclusive",
data: { props: found.join(", ") },
});
}
}
}
},
};
},
});Примеры ошибок:
// ❌ Отсутствует обязательный проп
<Button />
// → Error: Компонент Button требует проп: children
// ❌ Deprecated проп
<Button type="primary">Click</Button>
// → Error: Проп type устарел. Используйте variant вместо type
// ❌ Взаимоисключающие пропсы
<Button href="/page" onClick={handleClick}>Link</Button>
// → Error: Пропсы href, onClick взаимоисключающие
// ✅ Правильно
<Button variant="primary">Click</Button>Упаковка в npm пакет
Структура проекта
eslint-plugin-design-system/
├── src/
│ ├── rules/ # Правила
│ │ ├── no-hardcoded-colors.ts
│ │ ├── no-deep-imports.ts
│ │ ├── valid-component-props.ts
│ │ └── __tests__/ # Тесты
│ ├── helpers/ # Вспомогательные функции
│ │ ├── color-detector.ts
│ │ ├── fsd-detector.ts
│ │ └── fsd-layers.ts
│ ├── configs/ # Пресеты конфигураций
│ │ ├── recommended.ts
│ │ └── strict.ts
│ └── index.ts # Главный файл
├── package.json
├── tsconfig.json
└── README.md
Главный файл (index.ts)
// src/index.ts
import noHardcodedColors from "./rules/no-hardcoded-colors";
import noDeepImports from "./rules/no-deep-imports";
import validComponentProps from "./rules/valid-component-props";
import recommended from "./configs/recommended";
import strict from "./configs/strict";
// Экспортируем как CommonJS модуль (требование ESLint)
export = {
// Правила
rules: {
"no-hardcoded-colors": noHardcodedColors,
"no-deep-imports": noDeepImports,
"valid-component-props": validComponentProps,
},
// Пресеты конфигураций
configs: {
recommended,
strict,
},
};Пресет конфигурации
// src/configs/recommended.ts
export default {
plugins: ["@company/design-system"],
rules: {
// warning для не-критичных правил
"@company/design-system/no-hardcoded-colors": "warn",
// error для архитектурных правил
"@company/design-system/no-deep-imports": "error",
"@company/design-system/valid-component-props": "error",
},
};package.json
{
"name": "@company/eslint-plugin-design-system",
"version": "1.0.0",
"description": "ESLint правила для дизайн-системы компании",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"keywords": ["eslint", "eslintplugin", "design-system", "fsd"],
"scripts": {
"build": "tsc",
"test": "vitest",
"test:watch": "vitest --watch",
"prepublishOnly": "npm run build"
},
"peerDependencies": {
"eslint": "^8.0.0 || ^9.0.0"
},
"devDependencies": {
"@typescript-eslint/utils": "^6.0.0",
"@typescript-eslint/rule-tester": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"typescript": "^5.0.0",
"vitest": "^1.0.0"
}
}Публикация
# 1. Собираем
npm run build
# 2. Тестируем локально
cd ../my-app
npm link ../eslint-plugin-design-system
# 3. Публикуем в npm
npm login
npm publish --access publicИспользование в проектах
npm install --save-dev @company/eslint-plugin-design-system// .eslintrc.js
module.exports = {
// Вариант 1: Использовать пресет
extends: ["plugin:@company/design-system/recommended"],
// Вариант 2: Настроить вручную
plugins: ["@company/design-system"],
rules: {
"@company/design-system/no-hardcoded-colors": "error",
"@company/design-system/no-deep-imports": "error",
},
};Продвинутые техники
1. Интеграция с TypeScript типами
Иногда нужно проверять типы, а не только AST. Например, проверить, что функция возвращает Promise.
import { ESLintUtils } from "@typescript-eslint/utils";
export const rule = ESLintUtils.RuleCreator(/* ... */)({
name: "async-handler-return-type",
meta: {
/* ... */
},
create(context) {
// Получаем доступ к TypeScript компилятору
const parserServices = ESLintUtils.getParserServices(context);
const checker = parserServices.program.getTypeChecker();
return {
FunctionDeclaration(node) {
// Конвертируем ESTree узел в TypeScript узел
const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node);
// Получаем тип из TypeScript
const signature = checker.getSignatureFromDeclaration(tsNode);
if (!signature) return;
const returnType = checker.getReturnTypeOfSignature(signature);
// Проверяем, что возвращает Promise
const isPromise = checker
.typeToString(returnType)
.startsWith("Promise<");
if (!isPromise && node.async) {
context.report({
node,
message: "Async функция должна возвращать Promise",
});
}
},
};
},
});Когда использовать:
- Проверка типов возвращаемых значений
- Валидация generic параметров
- Проверка совместимости типов
2. Suggestions вместо автофиксов
Для неоднозначных случаев используйте suggestions — предложения исправлений, которые пользователь выбирает вручную.
context.report({
node,
messageId: "deprecatedApi",
suggest: [
{
messageId: "replaceWithNew",
fix(fixer) {
return fixer.replaceText(node, "newAPI()");
},
},
{
messageId: "removeCompletely",
fix(fixer) {
return fixer.remove(node);
},
},
],
});В IDE это выглядит так:
⚠ Deprecated API: oldAPI()
Quick fixes:
1. Replace with newAPI()
2. Remove completely
3. Кеширование для производительности
Если проверка дорогая (например, чтение файлов), кешируйте результаты:
create(context) {
// Кеш живёт только во время проверки одного файла
const cache = new Map<string, boolean>();
function isValidImport(importPath: string): boolean {
if (cache.has(importPath)) {
return cache.get(importPath)!;
}
// Дорогая проверка (например, fs.existsSync)
const result = expensiveCheck(importPath);
cache.set(importPath, result);
return result;
}
return {
ImportDeclaration(node) {
const valid = isValidImport(node.source.value);
// ...
},
};
}Интеграция в workflow
CI/CD проверки
# .github/workflows/lint.yml
name: Lint
on: [pull_request]
jobs:
eslint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint -- --format json --output-file eslint-report.json
- name: Comment PR with results
uses: actions/github-script@v6
if: always()
with:
script: |
const fs = require('fs');
const report = JSON.parse(fs.readFileSync('eslint-report.json', 'utf8'));
const violations = report.reduce((sum, file) =>
sum + file.errorCount + file.warningCount, 0
);
const message = violations === 0
? '✅ No ESLint violations found!'
: `⚠️ Found ${violations} ESLint violations`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: message
});Pre-commit хуки
# Установка husky
npm install --save-dev husky lint-staged
npx husky init// .husky/pre-commit
npm run lint-staged// lint-staged.config.js
module.exports = {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"git add", // Добавляем автофиксы в коммит
],
};Метрики и статистика
Создайте кастомный formatter для сбора статистики:
// scripts/eslint-stats-formatter.ts
import type { ESLint } from "eslint";
export default function formatter(results: ESLint.LintResult[]): string {
const stats = new Map<string, number>();
let totalErrors = 0;
let totalWarnings = 0;
// Собираем статистику
for (const result of results) {
totalErrors += result.errorCount;
totalWarnings += result.warningCount;
for (const message of result.messages) {
const ruleId = message.ruleId || "unknown";
stats.set(ruleId, (stats.get(ruleId) || 0) + 1);
}
}
// Сортируем по количеству
const sorted = Array.from(stats.entries()).sort((a, b) => b[1] - a[1]);
// Выводим топ-10
console.log("\n📊 ESLint Statistics\n");
console.log(`Total: ${totalErrors} errors, ${totalWarnings} warnings\n`);
console.log("Top 10 violations:");
for (const [rule, count] of sorted.slice(0, 10)) {
console.log(` ${rule.padEnd(50)} ${count}`);
}
return "";
}Использование:
eslint . --format ./scripts/eslint-stats-formatter.tsРеальные кейсы использования
Кейс 1: Автоматическая миграция API
Задача: Дизайн-система обновилась, нужно заменить <Button type="primary"> на <Button variant="primary"> в 500+ файлах.
Решение: ESLint правило с автофиксом.
export const migrateButtonApi = ESLintUtils.RuleCreator(/* ... */)({
name: "migrate-button-api",
meta: {
type: "suggestion",
fixable: "code",
messages: {
migrate: "Button API изменился: используйте variant вместо type",
},
},
create(context) {
return {
JSXOpeningElement(node) {
if (getComponentName(node) !== "Button") return;
// Ищем атрибут type
const typeAttr = node.attributes.find(
(attr) =>
attr.type === "JSXAttribute" &&
attr.name.type === "JSXIdentifier" &&
attr.name.name === "type"
);
if (typeAttr) {
context.report({
node: typeAttr,
messageId: "migrate",
fix(fixer) {
// Просто переименовываем type → variant
return fixer.replaceText(typeAttr.name, "variant");
},
});
}
},
};
},
});Запуск:
# Включаем правило временно только для миграции
eslint . \
--rule '@company/design-system/migrate-button-api: error' \
--fix
# Результат: 500+ файлов обновлено за 2 секундыКейс 2: Контроль accessibility
Задача: Убедиться, что все кнопки с иконками имеют aria-label.
export const iconButtonA11y = ESLintUtils.RuleCreator(/* ... */)({
name: "icon-button-a11y",
meta: {
type: "problem",
messages: {
missingLabel:
"IconButton без текста должен иметь aria-label для screen readers",
},
},
create(context) {
return {
JSXOpeningElement(node) {
if (getComponentName(node) !== "IconButton") return;
// Проверяем наличие children (текста)
const hasChildren =
node.parent.type === "JSXElement" &&
node.parent.children.some(
(child) => child.type === "JSXText" && child.value.trim()
);
// Проверяем наличие aria-label
const hasAriaLabel = node.attributes.some(
(attr) =>
attr.type === "JSXAttribute" &&
attr.name.type === "JSXIdentifier" &&
attr.name.name === "aria-label"
);
// Если нет ни текста, ни aria-label — ошибка
if (!hasChildren && !hasAriaLabel) {
context.report({
node,
messageId: "missingLabel",
});
}
},
};
},
});Кейс 3: Производительность React
Задача: Запретить тяжелые операции (map, filter, sort) в рендере без useMemo.
export const noExpensiveRender = ESLintUtils.RuleCreator(/* ... */)({
name: "no-expensive-render",
meta: {
type: "problem",
messages: {
wrapInMemo: "Оберните {{ method }} в useMemo для оптимизации рендера",
},
},
create(context) {
const expensiveMethods = ["map", "filter", "reduce", "sort"];
// Проверяем, внутри ли мы React компонента
function isReactComponent(scope: any): boolean {
// Упрощенная проверка
return (
scope.block.type === "FunctionDeclaration" &&
/^[A-Z]/.test(scope.block.id?.name || "")
);
}
// Проверяем, внутри ли useMemo/useCallback
function isInsideHook(node: TSESTree.Node): boolean {
let parent = node.parent;
while (parent) {
if (parent.type === "CallExpression") {
const callee = parent.callee;
if (
callee.type === "Identifier" &&
(callee.name === "useMemo" || callee.name === "useCallback")
) {
return true;
}
}
parent = parent.parent;
}
return false;
}
return {
CallExpression(node) {
// Проверяем, что мы в компоненте
const scope = context.getScope();
if (!isReactComponent(scope.variableScope)) return;
// Проверяем, что НЕ внутри хука
if (isInsideHook(node)) return;
// Ловим array.map(), array.filter() и т.д.
if (
node.callee.type === "MemberExpression" &&
node.callee.property.type === "Identifier" &&
expensiveMethods.includes(node.callee.property.name)
) {
context.report({
node,
messageId: "wrapInMemo",
data: { method: node.callee.property.name },
});
}
},
};
},
});Результат:
// ❌ Ошибка
function UserList({ users }) {
return (
<div>
{users
.filter((u) => u.active)
.map((u) => (
<User key={u.id} data={u} />
))}
</div>
);
}
// → Error: Оберните filter в useMemo для оптимизации рендера
// ✅ Правильно
function UserList({ users }) {
const activeUsers = useMemo(() => users.filter((u) => u.active), [users]);
return (
<div>
{activeUsers.map((u) => (
<User key={u.id} data={u} />
))}
</div>
);
}FAQ и устранение проблем
Как дебажить правила?
create(context) {
return {
JSXAttribute(node) {
// Логируем всё что видим
console.log('Node:', node);
console.log('Type:', node.type);
console.log('Value:', node.value);
// Или используйте debugger
debugger;
},
};
}Затем запустите:
node --inspect-brk ./node_modules/.bin/eslint yourfile.jsПравило не срабатывает
Проблема: Написали правило, но ESLint его не видит.
Решения:
- Проверьте, что правило экспортируется в
index.ts - Проверьте, что правило включено в
.eslintrc.js - Перезапустите ESLint сервер в IDE (VSCode: Cmd+Shift+P → "Restart ESLint Server")
- Проверьте селектор узла в AST Explorer
Автофикс не работает
Проблема: eslint --fix не исправляет код.
Решения:
- Проверьте, что
fixable: 'code'вmeta - Проверьте, что
fix()возвращаетfixer.*(неundefined) - Если несколько правил конфликтуют — автофиксы отключаются
- Используйте
suggestвместоfixдля неоднозначных случаев
Правило слишком медленное
Проблема: ESLint работает долго после добавления правила.
Решения:
- Кешируйте результаты (см. раздел "Кеширование")
- Избегайте
fsопераций в правилах - Не используйте сложные regex в циклах
- Профилируйте:
TIMING=1 eslint .
TIMING=1 eslint .
Rule | Time (ms) | Relative
:-----------------------|----------:|--------:
my-rule/no-deep-imports | 1234.5 | 45.2% ← медленно!Заключение
Кастомные ESLint правила — это автоматизация архитектурных требований. Вместо code review каждого MR вы получаете мгновенную обратную связь в IDE.
Ключевые выводы
- Статический анализ > документация — разработчики не читают docs, но видят ошибки в IDE
- Автофикс > ручные правки — миграции на новые API выполняются за секунды
- Раннее обнаружение > поздний рефакторинг — нарушения архитектуры не попадают в main
- Измеримость — можно отслеживать технический долг через метрики ESLint
Когда стоит писать правило
✅ Пишите правило, если:
- Проблема повторяется > 3 раз в code review
- Нарушение приводит к критическим багам (производительность, a11y, security)
- Проверка алгоритмизируема (можно выразить через AST)
- Автофикс очевиден и безопасен
❌ Не пишите правило, если:
- Проблема требует бизнес-контекста
- Проверка слишком эвристична (много false positives)
- Проще написать unit-тест или интеграционный тест
Следующие шаги
- Изучите AST вашего кода — откройте astexplorer.net
- Начните с простого — напишите правило из раздела "Быстрый старт"
- Добавьте тесты — покройте edge cases с помощью
RuleTester - Соберите feedback — включите правило как
warningна неделю - Автоматизируйте миграции — используйте autofixes для рефакторингов
Хотите внедрить ESLint плагины в вашу дизайн-систему? Я помогаю командам автоматизировать контроль качества кода и архитектуры. Свяжитесь со мной для консультации.
