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

Понемногу — но каждый день.

Свои ESLint-правила как защита от повторяющихся ошибок

6 мин чтения

На этой странице
Коротко
Код-ревью ловит ошибку один раз. Правило линтера ловит её всегда: в каждом коммите, у каждого разработчика, в CI. Моё правило простое: если одна и та же ошибка дошла до ревью второй раз, это уже не ошибка человека, а дыра в инструментах. Дальше — лестница из трёх ступеней (готовое правило → ограничение с сообщением → своё правило), разбор своего правила по шагам и как понять, что оно вообще работает.

Почему ревью не хватает

Ревью делает человек. Человек устаёт, торопится, смотрит на логику и пропускает мелочь, которую видел уже десять раз. А новый разработчик в команде эту мелочь вообще не знает: она живёт только в головах тех, кто однажды из-за неё чинил прод.

Линтер работает иначе:

  • Он не устаёт. Сотый файл проверяется так же, как первый.
  • Он срабатывает раньше. Ошибка видна в редакторе в момент набора, а не через день в комментарии к MR.
  • Он хранит знание. Правило с понятным сообщением — это документация, которую невозможно не прочитать.

Поэтому каждое правило у меня — ответ на реальный баг. Не «так красивее», а «вот здесь мы уже обожглись».

Лестница: от дешёвого к дорогому

Своё правило — самая дорогая ступень. Его надо написать, протестировать и поддерживать. Поэтому сначала проверяю две ступени ниже.

Ступень 1. Готовое правило

Почти всегда кто-то уже наступил на те же грабли. Где искать:

  • встроенные правила ESLint;
  • typescript-eslint — ошибки с типами (например, забытый await ловит no-floating-promises);
  • angular-eslint — компоненты, шаблоны, жизненный цикл;
  • в Nx-монорепозитории — @nx/enforce-module-boundaries: запрещает библиотекам импортировать то, что им не положено.

Включить готовое правило — пять минут. Написать своё — час или больше.

Ступень 2. Запрет с объяснением

Если готового правила нет, но ошибка выглядит как «нельзя использовать X», хватает встроенных no-restricted-*. Пример: в приложении с SSR (рендеринг на сервере) прямой доступ к localStorage ломает рендер, потому что на сервере его нет. Значит, все ходят через сервис-обёртку:

// eslint.config.js (фрагмент)
{
  files: ['apps/**/*.ts', 'libs/**/*.ts'],
  ignores: ['libs/shared/storage/**'], // здесь живёт сама обёртка
  rules: {
    'no-restricted-globals': ['error', {
      name: 'localStorage',
      message: 'Используй StorageService: при SSR localStorage нет.',
    }],
    'no-restricted-properties': ['error', {
      object: 'window',
      property: 'localStorage',
      message: 'Используй StorageService: при SSR localStorage нет.',
    }],
  },
}

Обрати внимание на второе правило. Первое ловит localStorage.getItem(), но пропускает window.localStorage.getItem(). Любой запрет надо проверить на обходы, иначе он даёт ложное чувство безопасности.

Для чуть более сложных случаев есть no-restricted-syntax: он принимает селектор по дереву кода (об этом ниже) и сообщение. Это уже почти своё правило, только без отдельного файла.

Ступень 3. Своё правило

Нужно, когда проверка зависит от контекста: «вызов X допустим, только если рядом есть Y». Селектором это не выразить, нужна логика.

Как линтер видит код

Перед тем как писать правило, надо понять одну идею. ESLint не читает код как текст. Он сначала превращает его в AST — абстрактное синтаксическое дерево. Каждая конструкция становится узлом с типом и полями.

Например, строка this.items$.subscribe() — это:

CallExpression                 ← вызов функции
└── callee: MemberExpression   ← обращение «объект.свойство»
    ├── object:   this.items$
    └── property: subscribe

Правило — это функция, которая говорит ESLint: «когда будешь обходить дерево и встретишь узел такого-то типа, позови меня». Посмотреть дерево любого кода можно в песочнице typescript-eslint или на astexplorer.net (ссылки в конце). Я всегда начинаю с этого: вставляю пример с ошибкой и смотрю, какие узлы надо ловить.

Разбор своего правила

Учебная задача. В Angular-компоненте подписка через subscribe() без takeUntilDestroyed() переживает сам компонент: компонент уничтожен, а подписка продолжает работать и тратить память. Ошибка тихая, в тестах не видна, проявляется через полчаса работы приложения.

Что ловим: вызов .subscribe(), перед которым нет .pipe(..., takeUntilDestroyed(...)).

// tools/lint-rules/rules/subscribe-needs-destroy.js
export default {
  meta: {
    type: 'problem',
    docs: {
      description: 'subscribe() в компоненте должен идти после takeUntilDestroyed()',
    },
    messages: {
      missing:
        'Добавь takeUntilDestroyed() в pipe() перед subscribe(), иначе подписка переживёт компонент.',
    },
    schema: [],
  },

  create(context) {
    return {
      // селектор: любой вызов, где имя свойства — subscribe
      'CallExpression[callee.property.name="subscribe"]'(node) {
        const target = node.callee.object; // то, у чего вызвали subscribe

        const isPipe =
          target.type === 'CallExpression' &&
          target.callee.type === 'MemberExpression' &&
          target.callee.property.name === 'pipe';

        const hasDestroy =
          isPipe &&
          target.arguments.some(
            (arg) =>
              arg.type === 'CallExpression' &&
              arg.callee.name === 'takeUntilDestroyed',
          );

        if (!hasDestroy) {
          context.report({ node: node.callee.property, messageId: 'missing' });
        }
      },
    };
  },
};

По частям:

  1. meta — описание правила. Самое важное здесь messages: текст, который увидит разработчик. Хорошее сообщение отвечает на два вопроса: что сделать и почему. «Запрещено» без причины люди отключают комментарием.
  2. create возвращает объект: ключ — селектор узла, значение — функция. ESLint вызовет её для каждого подходящего узла.
  3. Проверка идёт от subscribe назад: что стоит перед ним? Если это не pipe(...) или внутри pipe нет takeUntilDestroyed, сообщаем.
  4. context.report подсвечивает именно слово subscribe, а не всю строку — так ошибку видно сразу.

Подключение в flat config:

// eslint.config.js (фрагмент)
import subscribeNeedsDestroy from './tools/lint-rules/rules/subscribe-needs-destroy.js';

export default [
  // ...базовый конфиг с парсером typescript-eslint
  {
    files: ['**/*.component.ts'], // в сервисах другие правила жизни подписок
    plugins: {
      local: { rules: { 'subscribe-needs-destroy': subscribeNeedsDestroy } },
    },
    rules: { 'local/subscribe-needs-destroy': 'error' },
  },
];

Поле files здесь не мелочь. Правило, которое срабатывает там, где оно не нужно, быстро начинают отключать — и вместе с ним теряют доверие ко всему линтеру.

Тест: код с багом — это и есть тест

Правило без тестов ломается при первом рефакторинге. В ESLint для этого есть RuleTester: даёшь ему примеры, где ошибки нет (valid), и примеры, где она есть (invalid).

// tools/lint-rules/rules/subscribe-needs-destroy.test.js
import { RuleTester } from 'eslint';
import tsParser from '@typescript-eslint/parser';
import rule from './subscribe-needs-destroy.js';

const tester = new RuleTester({ languageOptions: { parser: tsParser } });

tester.run('subscribe-needs-destroy', rule, {
  valid: [
    'this.items$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe();',
    'this.items$.pipe(map(toView), takeUntilDestroyed()).subscribe();',
  ],
  invalid: [
    { code: 'this.items$.subscribe();', errors: [{ messageId: 'missing' }] },
    { code: 'this.items$.pipe(map(toView)).subscribe();', errors: [{ messageId: 'missing' }] },
  ],
});

Мой порядок: сначала в invalid кладу упрощённую копию того кода, который реально сломался. Потом пишу правило, пока тест не станет зелёным. Это обычный TDD, только объект теста — сам линтер.

Компромиссы и дыры

Честно о том, чего это правило не умеет:

  • Это эвристика, а не анализ типов. Правило смотрит на имена. Если вызвать subscribe у чего-то, что не Observable, будет ложное срабатывание. Если завернуть takeUntilDestroyed в свою функцию, правило её не узнает.
  • Есть законные исключения. Например, HTTP-запрос, который завершается сам. Для таких мест — // eslint-disable-next-line с объяснением в комментарии. Если исключений становится много, значит правило сформулировано неточно.
  • Не всё нужно ловить линтером. Если ошибка про бизнес-логику, а не про форму кода, её место в тестах, а не в ESLint.

Как понять, что правило работает

Без цифр это просто ощущение. Минимум, который я считаю:

  1. Первый запуск. Сколько нарушений правило нашло в существующем коде. Если ноль, скорее всего, оно ищет не то или баг был единичным.
  2. Постепенное включение. Сначала warn, чтобы починить старое без блокировки CI, потом error.
  3. Повторы. Дошла ли эта ошибка до ревью или прода после включения.

Посчитать нарушения одного правила:

npx eslint . -f json \
  | jq '[.[].messages[] | select(.ruleId == "local/subscribe-needs-destroy")] | length'

Если хочется повторить

  1. Заведи список повторов. Каждый раз, когда пишешь в ревью одно и то же замечание второй раз, записывай его.
  2. Поднимись по лестнице: готовое правило → no-restricted-* с сообщением → своё правило.
  3. Начни с теста: код с реальной ошибкой в invalid.
  4. Пиши сообщение для новичка: что сделать и почему.
  5. Ограничь files только там, где правило имеет смысл.
  6. Включай через warn, считай нарушения, потом error.

Что дальше

Следующий шаг — правила с автоисправлением (fixer), чтобы eslint --fix чинил ошибку сам, и правила для Angular-шаблонов через парсер angular-eslint. Об этом — в посте eslint-fixer-angular-templates.

Источники