238 lines
14 KiB
JavaScript
238 lines
14 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];
|
||
|
||
// Цветные атрибуты 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,
|
||
);
|