ESLint-правила, которые чинят сами: fixer, suggestions и Angular-шаблоны
На этой странице
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}`);
},
});
},
};
},
};Что здесь нового по сравнению с прошлым постом:
meta.fixable: 'code'— без этого поля ESLint откажется применять исправление. Это защита от правил, которые чинят «случайно».fix(fixer)внутриcontext.report. Функция получаетfixer— набор безопасных операций над текстом:replaceText,insertTextBefore,insertTextAfter,removeи их версии сRange. Мы не пишем файл сами — мы описываем правку, а ESLint применяет её.- Сохраняем стиль кода.
node.source.raw[0]— это кавычка, которая стоит в исходнике. Автофикс не должен спорить с Prettier. - Опасный случай — без 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-правила:
getTemplateParserServices(context)— помощник из@angular-eslint/utils. У узлов шаблона нет обычногоloc, как у узлов TS, а естьsourceSpanот компилятора Angular.convertNodeSourceSpanToLocпереводит одно в другое, чтобы ESLint подсветил нужное место.- Смещения считаем сами.
startSourceSpan.start.offset— позиция символа<в файле. Вставляем текст сразу после<button, поэтому используемinsertTextAfterRange. meta.hasSuggestions: trueвместоfixable. Каждый вариант вsuggest— это свойmessageId(что увидит человек в меню редактора) и своя функцияfix.- Проверяем обе формы
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 говорит «есть проблема», редактор помогает её решить.
Если хочется повторить
- Задай вопрос: может ли это исправление хоть раз изменить поведение? Да → suggestion. Нет → fix.
- Найди исключения до кода. Почитай официальную документацию того, что чинишь: у RxJS исключения описаны прямо в гайде по импорту.
- Тестируй
outputдля каждой правки иoutput: nullдля случаев, где правки быть не должно. - Для шаблонов смотри дерево через парсер angular-eslint и помни про обе формы атрибута: статическую и привязку.
- После первого массового
--fix—tsc, тесты и отдельный коммит, чтобы его легко было откатить.
Что дальше
Оба правила пока смотрят только на синтаксис: имена, атрибуты, пути. Следующий уровень — правила с информацией о типах (typed linting в typescript-eslint). Например, «это действительно Observable?» вместо «метод называется subscribe». Это медленнее, но убирает ложные срабатывания из прошлого поста.