Перейти к содержимому

Кастомные ESLint плагины для дизайн-систем: от теории к практике

Константин Потапов
30 min

Как создать собственные ESLint правила для автоматизации контроля качества кода в дизайн-системах. Практическое руководство с примерами реальных плагинов.

Кастомные ESLint плагины для дизайн-систем: от теории к практике

Дизайн-системы решают проблему консистентности 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 анализирует код.

Жизненный цикл проверки

Процесс пошагово:

  1. Parser (например, @typescript-eslint/parser) читает ваш код
  2. Преобразует его в AST (Abstract Syntax Tree) — дерево узлов
  3. ESLint проходит по дереву и вызывает ваши правила для каждого узла
  4. Правила проверяют узлы и сообщают об ошибках
  5. Опционально: правила предлагают автофиксы

Что такое 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
      },
    };
  },
};

Как это работает:

  1. ESLint проходит по AST дереву
  2. Для каждого узла типа Literal вызывает функцию Literal(node)
  3. Внутри функции вы проверяете узел
  4. Если находите проблему — вызываете 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");
            },
          });
        }
      },
    };
  },
};

Разбор кода:

  1. CallExpression — узел для любого вызова функции
  2. node.callee — то, что вызывается (в foo.bar() это foo.bar)
  3. MemberExpression — доступ к свойству объекта (console.log)
  4. node.callee.object.name — имя объекта (console)
  5. 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;

Как это работает:

  1. JSXAttribute — ловим <Box color="#FF0000" />
  2. Property — ловим sx={{ color: '#FF0000' }}
  3. 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";

Зачем это нужно:

  1. Инкапсуляция — можем менять внутреннюю структуру без breaking changes
  2. Контроль — явно указываем, что публичное, что приватное
  3. Переиспользование — один импорт для всех компонентов модуля

Шаг 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 его не видит.

Решения:

  1. Проверьте, что правило экспортируется в index.ts
  2. Проверьте, что правило включено в .eslintrc.js
  3. Перезапустите ESLint сервер в IDE (VSCode: Cmd+Shift+P → "Restart ESLint Server")
  4. Проверьте селектор узла в AST Explorer

Автофикс не работает

Проблема: eslint --fix не исправляет код.

Решения:

  1. Проверьте, что fixable: 'code' в meta
  2. Проверьте, что fix() возвращает fixer.* (не undefined)
  3. Если несколько правил конфликтуют — автофиксы отключаются
  4. Используйте suggest вместо fix для неоднозначных случаев

Правило слишком медленное

Проблема: ESLint работает долго после добавления правила.

Решения:

  1. Кешируйте результаты (см. раздел "Кеширование")
  2. Избегайте fs операций в правилах
  3. Не используйте сложные regex в циклах
  4. Профилируйте: TIMING=1 eslint .
TIMING=1 eslint .
 
Rule                    | Time (ms) | Relative
:-----------------------|----------:|--------:
my-rule/no-deep-imports |   1234.5  |   45.2% медленно!

Заключение

Кастомные ESLint правила — это автоматизация архитектурных требований. Вместо code review каждого MR вы получаете мгновенную обратную связь в IDE.

Ключевые выводы

  1. Статический анализ > документация — разработчики не читают docs, но видят ошибки в IDE
  2. Автофикс > ручные правки — миграции на новые API выполняются за секунды
  3. Раннее обнаружение > поздний рефакторинг — нарушения архитектуры не попадают в main
  4. Измеримость — можно отслеживать технический долг через метрики ESLint

Когда стоит писать правило

✅ Пишите правило, если:

  • Проблема повторяется > 3 раз в code review
  • Нарушение приводит к критическим багам (производительность, a11y, security)
  • Проверка алгоритмизируема (можно выразить через AST)
  • Автофикс очевиден и безопасен

❌ Не пишите правило, если:

  • Проблема требует бизнес-контекста
  • Проверка слишком эвристична (много false positives)
  • Проще написать unit-тест или интеграционный тест

Следующие шаги

  1. Изучите AST вашего кода — откройте astexplorer.net
  2. Начните с простого — напишите правило из раздела "Быстрый старт"
  3. Добавьте тесты — покройте edge cases с помощью RuleTester
  4. Соберите feedback — включите правило как warning на неделю
  5. Автоматизируйте миграции — используйте autofixes для рефакторингов

Хотите внедрить ESLint плагины в вашу дизайн-систему? Я помогаю командам автоматизировать контроль качества кода и архитектуры. Свяжитесь со мной для консультации.