textmachine/frontend/eslint.config.js

238 lines
14 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];
// Цветные атрибуты 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: `<div {...props}/>`, где 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,
);