import comments from '@eslint-community/eslint-plugin-eslint-comments/configs'; import js from '@eslint/js'; import prettier from 'eslint-config-prettier'; import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; import globals from 'globals'; import tseslint from 'typescript-eslint'; // Гейт «одно место для цвета и размера», половина в TSX (STACK_DECISIONS §3). // CSS-половина — в stylelint.config.js. const colorLiteral = 'Цвет живёт только в src/tokens/tokens.css. В коде — var(--color-...) из .module.css.'; const inlineStyle = 'Инлайновый стиль запрещён: размеры и цвета берутся из .module.css на токенах. ' + "Единственное исключение — передача CSS-переменной: style={{ '--progress': value }}."; const network = 'Сеть живёт в src/api/ и больше нигде — включая подписку на поток событий.'; // Длины перечислены все четыре: #RGB, #RGBA, #RRGGBB, #RRGGBBAA. Ловить «3..8 подряд» одним // шаблоном нельзя — граница слова не сработает внутри более длинной последовательности. const hexLengths = [3, 4, 6, 8]; const tokenGate = [ ...hexLengths.map((n) => ({ selector: `Literal[value=/#[0-9a-fA-F]{${n}}\\b/]`, message: colorLiteral, })), { selector: 'Literal[value=/\\b(rgba?|hsla?|hwb|oklch|oklab|lab|lch|color|light-dark)\\(/]', message: colorLiteral, }, { selector: 'TemplateElement[value.raw=/#[0-9a-fA-F]{3,8}\\b/]', message: colorLiteral }, // Запрещаем сам атрибут, а не перечень форм записи: перечисление ловило три случая из // одиннадцати — спред, вынесенная переменная, тернарник и фабрика проходили молча. // Разрешён ровно один вид — литерал объекта прямо в атрибуте; его свойства проверяются ниже. { selector: "JSXAttribute[name.name='style']:not(:has(JSXExpressionContainer > ObjectExpression))", message: inlineStyle, }, // Внутри разрешённого литерала: ключ-идентификатор — обычное свойство (color, width), // CSS-переменная синтаксически обязана быть строковым ключом, поэтому проходит только она. { selector: "JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > Property[key.type='Identifier']", message: inlineStyle, }, { selector: "JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > Property[key.type='Literal'][key.value!=/^--/]", message: inlineStyle, }, { selector: "JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > SpreadElement", message: inlineStyle, }, ]; export default tseslint.config( { ignores: ['dist/**', '.shots/**', '.tooling/**'] }, js.configs.recommended, // С доступом к типам: ловит «промис бросили и не дождались» — самый частый настоящий баг // React-приложения. Весь S3+ (загрузка, SSE-прогресс, запросы) асинхронный, и без этой сети // накопится к третьей сессии. Прогон на нашем src — около 4 секунд. tseslint.configs.recommendedTypeChecked, { languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, }, }, // Конфиги вне tsconfig — им типы не нужны. { files: ['**/*.js', '**/*.mjs'], extends: [tseslint.configs.disableTypeChecked] }, // .flat — именно flat-вариант: одноимённый ключ верхнего уровня остался в старом формате. reactHooks.configs.flat['recommended-latest'], reactRefresh.configs.vite, comments.recommended, { languageOptions: { globals: { ...globals.browser, ...globals.node }, }, rules: { 'no-restricted-syntax': ['error', ...tokenGate], // Симметрия с CSS-половиной, где голое отключение запрещено (reportUnscopedDisables). // noInlineConfig не берём: он запрещает и точечные подавления, которые рекомендует // сам React (react-hooks/exhaustive-deps). Отраслевая практика — не бинарный запрет, // а требование называть правило и писать причину. '@eslint-community/eslint-comments/no-unlimited-disable': 'error', '@eslint-community/eslint-comments/require-description': ['error', { ignore: [] }], // Глобальных стилей ровно два файла; всё остальное — CSS Modules, они скоупятся сами. // Плюс шов данных: моки видит только src/api/, и в день HTTP выбрасывается одна папка. 'no-restricted-imports': [ 'error', { patterns: [ { group: ['**/*.css', '!**/*.module.css'], message: 'Глобальный CSS импортируется только в src/main.tsx. Экранам и примитивам — *.module.css.', }, { group: ['**/mock/**'], message: 'Фикстуры импортирует только src/api/. Экраны зовут функции из src/api/ и не знают, откуда данные.', }, ], }, ], // Сеть — тоже только за швом: иначе запрос расползётся по экранам и его не выкорчевать. // Перечислены ВСЕ транспорты, а не один fetch: живой прогресс по ратифицированному // стеку приходит через EventSource (STACK_DECISIONS §5), и именно он естественнее // всего пишется прямо в экране — то есть мимо шва. 'no-restricted-globals': [ 'error', ...['fetch', 'EventSource', 'WebSocket', 'XMLHttpRequest'].map((name) => ({ name, message: network, })), ], // Тот же запрет через объект-носитель: `window.fetch(...)` обходит no-restricted-globals. 'no-restricted-properties': [ 'error', ...['window', 'globalThis', 'self'].flatMap((object) => ['fetch', 'EventSource', 'WebSocket', 'XMLHttpRequest'].map((property) => ({ object, property, message: network, })), ), { object: 'navigator', property: 'sendBeacon', message: network }, ], }, }, { // Точка сборки глобальных стилей: сброс, токены и шрифты подключаются здесь и больше нигде. files: ['src/main.tsx'], rules: { 'no-restricted-imports': 'off' }, }, { // Контракт-тест обязан называть замеренные цвета в лицо — иначе ему нечего сверять. files: ['src/tokens/*.test.ts'], rules: { 'no-restricted-syntax': 'off' }, }, { // Единственное место, где легальны и фикстуры, и сеть. files: ['src/api/**'], rules: { 'no-restricted-imports': 'off', 'no-restricted-globals': 'off', 'no-restricted-properties': 'off', }, }, prettier, );