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

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

ESLint-правила, которые чинят сами: fixer, suggestions и Angular-шаблоны

8 мин чтения

На этой странице
Коротко
В прошлом посте custom-eslint-rules правило только находило ошибку. Следующий шаг — чтобы оно её исправляло. Здесь главное не техника, а граница: eslint --fix можно доверить только то исправление, которое никогда не меняет поведение кода. Всё остальное — подсказка (suggestion), которую человек применяет сам. Плюс правила для Angular-шаблонов: они устроены так же, только дерево у них другое.

Три уровня помощи

Правило может сделать одно из трёх:

УровеньЧто делаетКто решаетКогда применяется
Только сообщениеПодсвечивает ошибкуЧеловек правит руками—
SuggestionПредлагает 1+ вариантов исправленияЧеловек выбирает в редактореПо клику, --fix их не трогает
FixИсправляет самМашинаeslint --fix, «fix on save», CI

Правило выбора одно: fix — только если исправление безопасно в 100 % случаев. Если хотя бы в одном случае оно может изменить поведение, это suggestion. --fix часто запускается сам — при сохранении файла или в pre-commit-хуке. Никто не смотрит на каждую его правку, поэтому ошибка автофикса — это баг, который никто не заметил.

Fix: путь импорта RxJS

Задача. С RxJS 7.2 операторы импортируются из 'rxjs', а путь 'rxjs/operators' устарел и уйдёт в одной из следующих мажорных версий. В большом проекте таких импортов сотни — чинить руками скучно, и это идеальная работа для --fix.

Сначала кажется, что всё просто: поменять строку 'rxjs/operators' на 'rxjs'. Но в официальном гайде RxJS есть деталь: несколько старых операторов (merge, concat, zip, race и др.) остались только в 'rxjs/operators'. А в 'rxjs' под тем же именем лежит другая функция — статическая, а не оператор. Наивный автофикс молча поменял бы смысл кода, и TypeScript мог бы это даже не заметить.

Поэтому правило делит случаи на два:

// tools/lint-rules/rules/rxjs-operators-import.js
// Эти имена в 'rxjs' — другие функции (статические), а не операторы из 'rxjs/operators'.
// Их нельзя чинить заменой пути: смысл кода изменится.
const SAME_NAME_DIFFERENT_FUNCTION = new Set([
  'combineLatest', 'concat', 'merge', 'onErrorResumeNext', 'partition', 'race', 'zip',
]);

export default {
  meta: {
    type: 'suggestion',
    fixable: 'code',
    docs: { description: 'Операторы импортируются из rxjs, а не из rxjs/operators' },
    messages: {
      deprecated: "Импортируй из 'rxjs': путь 'rxjs/operators' устарел с RxJS 7.2.",
      manual:
        "'{{name}}' в 'rxjs' — другая функция с тем же именем. Замени вручную (для merge, concat, zip, race — на mergeWith и т.п.).",
    },
    schema: [],
  },
  create(context) {
    return {
      'ImportDeclaration[source.value="rxjs/operators"]'(node) {
        const risky = node.specifiers.find(
          (s) => s.type === 'ImportSpecifier' && SAME_NAME_DIFFERENT_FUNCTION.has(s.imported.name),
        );

        if (risky) {
          // сообщаем, но НЕ чиним автоматически
          context.report({ node: risky, messageId: 'manual', data: { name: risky.imported.name } });
          return;
        }

        context.report({
          node: node.source,
          messageId: 'deprecated',
          fix(fixer) {
            const quote = node.source.raw[0]; // сохраняем стиль кавычек
            return fixer.replaceText(node.source, `${quote}rxjs${quote}`);
          },
        });
      },
    };
  },
};

Что здесь нового по сравнению с прошлым постом:

  1. meta.fixable: 'code' — без этого поля ESLint откажется применять исправление. Это защита от правил, которые чинят «случайно».
  2. fix(fixer) внутри context.report. Функция получает fixer — набор безопасных операций над текстом: replaceText, insertTextBefore, insertTextAfter, remove и их версии с Range. Мы не пишем файл сами — мы описываем правку, а ESLint применяет её.
  3. Сохраняем стиль кода. node.source.raw[0] — это кавычка, которая стоит в исходнике. Автофикс не должен спорить с Prettier.
  4. Опасный случай — без fix. Если в импорте есть одно из «двойных» имён, правило только сообщает. Ошибку видно, но решение остаётся за человеком.

Пункт 4 — это и есть главный урок поста. Код правила стал на 15 строк длиннее, но теперь --fix можно спокойно запускать на всём репозитории.

Suggestion: <button> без type

Теперь шаблон. Тихий баг из HTML: у <button> по умолчанию type="submit". Если кнопка стоит внутри <form> и у неё нет type, клик отправляет форму — даже если кнопка должна была просто открыть выпадающий список.

Можно ли это чинить автоматически? Нет. Мы не знаем, что задумал автор: просто кнопку (type="button") или отправку формы (type="submit"). Значит, это suggestion с двумя вариантами.

Честно о примере
В angular-eslint уже есть готовое правило @angular-eslint/template/button-has-type. В реальном проекте включи его — это ступень 1 из прошлого поста. Здесь я пишу своё только потому, что оно короткое и хорошо показывает, как устроены правила для шаблонов.

Как ESLint читает шаблон

ESLint сам не понимает HTML-шаблоны Angular. Это делает парсер @angular-eslint/template-parser: он берёт настоящий компилятор Angular (@angular/compiler) и превращает шаблон в дерево. Идея та же, что с TypeScript, только узлы другие:

<button (click)="save()">Сохранить</button>

Element  name="button"
├── attributes: []                  ← обычные атрибуты: type="button"
├── inputs:     []                  ← привязки: [type]="kind", [attr.type]="kind"
├── outputs:    [BoundEvent click]  ← события: (click)="save()"
└── children:   [Text "Сохранить"]

Новый синтаксис тоже есть в дереве: IfBlock, ForLoopBlock, SwitchBlock, DeferredBlock. Значит, правило найдёт кнопку и внутри @if { … }.

Правило

// tools/lint-rules/rules/button-has-type.js
import { getTemplateParserServices } from '@angular-eslint/utils';

export default {
  meta: {
    type: 'problem',
    hasSuggestions: true,
    docs: { description: 'У <button> должен быть явный type' },
    messages: {
      missing: 'Укажи type у <button>: внутри <form> без него кнопка отправляет форму.',
      addButton: 'Добавить type="button" (просто кнопка)',
      addSubmit: 'Добавить type="submit" (отправка формы)',
    },
    schema: [],
  },
  create(context) {
    const parserServices = getTemplateParserServices(context);

    return {
      'Element[name="button"]'(node) {
        const hasType =
          node.attributes.some((attr) => attr.name === 'type') || // type="..."
          node.inputs.some((input) => input.name === 'type');     // [type]="..." или [attr.type]

        if (hasType) return;

        // позиция сразу после "<button"
        const start = node.startSourceSpan.start.offset;
        const range = [start, start + '<button'.length];
        const insert = (value) => (fixer) =>
          fixer.insertTextAfterRange(range, ` type="${value}"`);

        context.report({
          loc: parserServices.convertNodeSourceSpanToLoc(node.startSourceSpan),
          messageId: 'missing',
          suggest: [
            { messageId: 'addButton', fix: insert('button') },
            { messageId: 'addSubmit', fix: insert('submit') },
          ],
        });
      },
    };
  },
};

Отличия от TypeScript-правила:

  1. getTemplateParserServices(context) — помощник из @angular-eslint/utils. У узлов шаблона нет обычного loc, как у узлов TS, а есть sourceSpan от компилятора Angular. convertNodeSourceSpanToLoc переводит одно в другое, чтобы ESLint подсветил нужное место.
  2. Смещения считаем сами. startSourceSpan.start.offset — позиция символа < в файле. Вставляем текст сразу после <button, поэтому используем insertTextAfterRange.
  3. meta.hasSuggestions: true вместо fixable. Каждый вариант в suggest — это свой messageId (что увидит человек в меню редактора) и своя функция fix.
  4. Проверяем обе формы type: статический атрибут (attributes) и привязку (inputs). Если забыть про [type]="kind", правило будет ругаться на правильный код — и его быстро отключат.

Тесты: output для fix и для каждой подсказки

В RuleTester у исправлений своё поле. Для fix — output: каким должен стать код после --fix. Для suggestion — output внутри каждой подсказки. А output: null означает «исправления быть не должно» — так тестируется опасный случай.

// tools/lint-rules/rules/autofix.test.js
import { RuleTester } from 'eslint';
import tsParser from '@typescript-eslint/parser';
import templateParser from '@angular-eslint/template-parser';
import rxjsRule from './rxjs-operators-import.js';
import buttonRule from './button-has-type.js';

new RuleTester({ languageOptions: { parser: tsParser } }).run('rxjs-operators-import', rxjsRule, {
  valid: ["import { map } from 'rxjs';"],
  invalid: [
    { code: "import { map } from 'rxjs/operators';", output: "import { map } from 'rxjs';", errors: [{ messageId: 'deprecated' }] },
    { code: 'import { map } from "rxjs/operators";', output: 'import { map } from "rxjs";', errors: [{ messageId: 'deprecated' }] },
    { code: "import { map, merge } from 'rxjs/operators';", output: null, errors: [{ messageId: 'manual' }] },
  ],
});

new RuleTester({ languageOptions: { parser: templateParser } }).run('button-has-type', buttonRule, {
  valid: [
    '<button type="button">Ок</button>',
    '<button [type]="kind">Ок</button>',
    '<button [attr.type]="kind">Ок</button>',
  ],
  invalid: [
    {
      code: '<form><button (click)="save()">Сохранить</button></form>',
      errors: [{
        messageId: 'missing',
        suggestions: [
          { messageId: 'addButton', output: '<form><button type="button" (click)="save()">Сохранить</button></form>' },
          { messageId: 'addSubmit', output: '<form><button type="submit" (click)="save()">Сохранить</button></form>' },
        ],
      }],
    },
    {
      code: '@if (ready) { <button>Ок</button> }',
      errors: [{
        messageId: 'missing',
        suggestions: [
          { messageId: 'addButton', output: '@if (ready) { <button type="button">Ок</button> }' },
          { messageId: 'addSubmit', output: '@if (ready) { <button type="submit">Ок</button> }' },
        ],
      }],
    },
  ],
});

Тест output: null для merge для меня самый важный во всём файле. Он гарантирует: даже если кто-то потом «упростит» правило, опасный автофикс не вернётся незамеченным.

Подключение

// eslint.config.js (фрагмент)
import tseslint from 'typescript-eslint';
import angular from 'angular-eslint';
import rxjsOperatorsImport from './tools/lint-rules/rules/rxjs-operators-import.js';
import buttonHasType from './tools/lint-rules/rules/button-has-type.js';

export default [
  {
    files: ['**/*.ts'],
    languageOptions: { parser: tseslint.parser },
    processor: angular.processInlineTemplates,
    plugins: { local: { rules: { 'rxjs-operators-import': rxjsOperatorsImport } } },
    rules: { 'local/rxjs-operators-import': 'error' },
  },
  {
    files: ['**/*.html'],
    languageOptions: { parser: angular.templateParser },
    plugins: { 'local-template': { rules: { 'button-has-type': buttonHasType } } },
    rules: { 'local-template/button-has-type': 'error' },
  },
];

Строка processor: angular.processInlineTemplates нужна для компонентов с template: '…' прямо в .ts-файле. Процессор вырезает такой шаблон в виртуальный .html-файл, и к нему применяется второй блок конфига. Одно правило работает и для отдельных .html, и для встроенных шаблонов — я проверил на обоих.

Что видно в терминале:

$ npx eslint --fix src
src/a.component.html
  1:7   error  Укажи type у <button>: внутри <form> без него кнопка отправляет форму

src/a.component.ts
  3:40  error  Укажи type у <button>: внутри <form> без него кнопка отправляет форму

✖ 2 problems (2 errors, 0 warnings)

Импорт RxJS в a.component.ts исправлен молча. Обе кнопки — в .html и во встроенном шаблоне — остались: --fix не применяет suggestions, и это правильно.

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

  • Автофикс правит текст, а не смысл. ESLint не перепроверяет, компилируется ли код после правки. После массового --fix обязательно запускай tsc и тесты.
  • Несколько правок в одном месте. Если два правила хотят поменять один и тот же участок, ESLint применит одно и пройдёт по файлу ещё раз (до 10 проходов). Обычно это работает, но правила, которые «спорят», лучше не включать вместе.
  • Список «опасных» имён — ручной. Если в будущей версии RxJS появится ещё одно такое имя, правило о нём не узнает. Поэтому список стоит в начале файла с комментарием, откуда он взят.
  • Suggestions видно только в редакторе. В CI это обычная ошибка. Для команды это нормально: CI говорит «есть проблема», редактор помогает её решить.

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

  1. Задай вопрос: может ли это исправление хоть раз изменить поведение? Да → suggestion. Нет → fix.
  2. Найди исключения до кода. Почитай официальную документацию того, что чинишь: у RxJS исключения описаны прямо в гайде по импорту.
  3. Тестируй output для каждой правки и output: null для случаев, где правки быть не должно.
  4. Для шаблонов смотри дерево через парсер angular-eslint и помни про обе формы атрибута: статическую и привязку.
  5. После первого массового --fix — tsc, тесты и отдельный коммит, чтобы его легко было откатить.

Что дальше

Оба правила пока смотрят только на синтаксис: имена, атрибуты, пути. Следующий уровень — правила с информацией о типах (typed linting в typescript-eslint). Например, «это действительно Observable?» вместо «метод называется subscribe». Это медленнее, но убирает ложные срабатывания из прошлого поста.

Источники