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]; // Цветные атрибуты SVG и иконок. Перечислять сами имена цветов бессмысленно (их 148 плюс // системные ключевые слова вроде Highlight), поэтому список — наоборот, разрешающий: ровно те же // значения, что допускает CSS-половина гейта. const colorAttributes = '/^(fill|stroke|color|stopColor|floodColor|lightingColor)$/'; const allowedColorValue = String.raw`/^(none|currentColor|inherit|transparent|var\(--)/`; const globalCss = { group: ['**/*.css', '!**/*.module.css'], message: 'Глобальный CSS импортируется только в src/main.tsx. Экранам и примитивам — *.module.css.', }; const mockFixtures = { group: ['**/mock/**'], message: 'Фикстуры импортирует только src/api/. Экраны зовут функции из src/api/ и не знают, откуда данные.', }; // Две библиотеки примитивов означали бы два focus-scope и два портальных менеджера в одном // приложении (STACK_DECISIONS §2), поэтому она одна и видна ровно из одного места. const primitives = { group: ['react-aria-components'], message: 'Примитивы живут в src/ui/. Оболочка и экраны ходят в наши обёртки, не в библиотеку.', }; // Те же три шва, но для ДИНАМИЧЕСКОГО импорта: `no-restricted-imports` разбирает только // объявление `import … from`, а `await import('../mock/book')` — выражение, и мимо шва проходило // всё три раза. Ключи совпадают с именами шаблонов выше, чтобы точечное послабление снималось // по имени шва, а не переписыванием селектора. `?inline`-суффикс не ловится ни здесь, ни в // статической половине — поведение у двух половин одинаковое, и это осознанно. const dynamicSeam = { globalCss: { selector: String.raw`ImportExpression > Literal[value=/\.css$/][value!=/\.module\.css$/]`, message: globalCss.message, }, mockFixtures: { selector: String.raw`ImportExpression > Literal[value=/(^|\/)mock\//]`, message: mockFixtures.message, }, primitives: { selector: "ImportExpression > Literal[value='react-aria-components']", message: primitives.message, }, }; const seams = (...names) => names.map((name) => dynamicSeam[name]); 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, }, // Санкционированное исключение пропускало ЛЮБОЕ значение, включая литеральный цвет: hex и // цветовые функции ловились общим правилом, а `'red'` и `Highlight` проходили. Значение // CSS-переменной обязано быть либо числом, либо ссылкой на токен. // Имя самой переменной тоже прямой потомок Property, поэтому `--` в списке разрешённых: // без него правило ловило собственный ключ и падало на легальном style={{ '--progress': x }}. { selector: `JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > Property[key.value=/^--/] > Literal[value!=${String.raw`/^(--|var\(--|[0-9.]+$)/`}]`, message: inlineStyle, }, // Носители литерала, которые проверка значения не видит насквозь: `cond ? 'red' : 'blue'` // и `` `${x}px` `` кладут в переменную ровно то, что запрещено строкой выше. Идентификатор // (`'--progress': value`) остаётся разрешённым — иначе исключение теряет смысл; вычисленное // значение проходит через именованную переменную, где оно осознанный шаг, а не инлайн. ...['ConditionalExpression', 'TemplateLiteral'].map((node) => ({ selector: `JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > Property[key.value=/^--/] > ${node}`, message: inlineStyle, })), // Остаточная дыра из §5.4: `
`, где style приезжает внутри объекта. Запрещаем // само свойство `style` в объектных литералах — тогда носитель ловится там, где он собран. { selector: "Property[key.name='style']", message: inlineStyle }, { selector: "Property[key.value='style']", message: inlineStyle }, // Именованные цвета в разметке: в CSS они запрещены, в TSX проходили — асимметрия из Ф-9. { selector: `JSXAttribute[name.name=${colorAttributes}] > Literal[value!=${allowedColorValue}]`, message: colorLiteral, }, { selector: `JSXAttribute[name.name=${colorAttributes}] > JSXExpressionContainer > Literal[value!=${allowedColorValue}]`, message: colorLiteral, }, { selector: `JSXAttribute[name.name=${colorAttributes}] > JSXExpressionContainer > TemplateLiteral`, message: colorLiteral, }, ]; 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, ...seams('globalCss', 'mockFixtures', 'primitives'), ], // Отключение обязано называть правило и объяснять причину. ⚠ Это НЕ полная симметрия // с CSS-половиной: там `reportDisables` делает ошибкой даже именованное отключение, // а здесь именованное с причиной проходит и гейт цвета на этой строке снимает. Такого // рычага у ESLint нет, и врать про симметрию не надо: у TSX-половины остаётся ручной // рубеж — отключение видно в дифе, и оно обязано нести написанную причину. // 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 выбрасывается одна папка. // Плюс шов примитивов: библиотека компонентов видна только из src/ui/. 'no-restricted-imports': ['error', { patterns: [globalCss, mockFixtures, primitives] }], // Сеть — тоже только за швом: иначе запрос расползётся по экранам и его не выкорчевать. // Перечислены ВСЕ транспорты, а не один 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', 'no-restricted-syntax': ['error', ...tokenGate, ...seams('mockFixtures', 'primitives')], }, }, { // Контракт-тест обязан называть замеренные цвета в лицо — иначе ему нечего сверять. // Гейт цвета снимается, швы остаются: их этому тесту нарушать незачем. files: ['src/tokens/*.test.ts'], rules: { 'no-restricted-syntax': ['error', ...seams('globalCss', 'mockFixtures', 'primitives')], }, }, { // Единственное место, где легальна библиотека примитивов. Остальные запреты в силе. files: ['src/ui/**'], rules: { 'no-restricted-imports': ['error', { patterns: [globalCss, mockFixtures] }], 'no-restricted-syntax': ['error', ...tokenGate, ...seams('globalCss', 'mockFixtures')], }, }, { // Единственное место, где легальны и фикстуры, и сеть. files: ['src/api/**'], rules: { 'no-restricted-imports': 'off', 'no-restricted-globals': 'off', 'no-restricted-properties': 'off', 'no-restricted-syntax': ['error', ...tokenGate, ...seams('globalCss', 'primitives')], }, }, prettier, );