textmachine/frontend/eslint.config.js

153 lines
8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
);