153 lines
8 KiB
JavaScript
153 lines
8 KiB
JavaScript
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,
|
||
);
|