From d8437d69dd438f9d615ba00fc5854b12c3f40271 Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sun, 2 Aug 2026 15:53:46 +0300 Subject: [PATCH] Close gate holes found by adversarial review: unscoped stylelint disables, lint warnings, inline-style forms, shot argument parsing and dangling token names now fail the check --- frontend/docs/BACKLOG.md | 3 ++ frontend/docs/FRONTEND_PLAN.md | 32 +++++++++++++---- frontend/eslint.config.js | 38 ++++++++++++++------ frontend/package.json | 2 +- frontend/scripts/shot.mjs | 44 +++++++++++++++++------ frontend/src/cssModules.test.ts | 17 +++++++-- frontend/src/react-css-variables.d.ts | 11 ++++++ frontend/src/showcase/Showcase.module.css | 9 +++-- frontend/src/showcase/Showcase.tsx | 5 +-- frontend/src/tokens/tokens.css | 1 + frontend/src/tokens/tokens.test.ts | 34 ++++++++++++++++++ frontend/stylelint.config.js | 10 +++++- 12 files changed, 167 insertions(+), 39 deletions(-) create mode 100644 frontend/src/react-css-variables.d.ts diff --git a/frontend/docs/BACKLOG.md b/frontend/docs/BACKLOG.md index 327be397..872cce1d 100644 --- a/frontend/docs/BACKLOG.md +++ b/frontend/docs/BACKLOG.md @@ -8,6 +8,9 @@ | Ф-2 | **React Compiler** — включить ОТДЕЛЬНЫМ шагом после заморозки интерфейса, с CI-проверкой, что вставки реально попали в бандл (в MVP выключен; совместимость держит линт eslint-plugin-react-hooks) | после заморозки UI | STACK_DECISIONS §1 | | Ф-3 | **Пересмотр TS 6 → TS 7** после выхода TS 7.1 с программным API (~октябрь 2026); решение обратимо — тайпчек не участвует в сборке | триггер: релиз TS 7.1 | STACK_DECISIONS §1 | | Ф-4 | **Токен-гейт на отступы** (`padding`/`margin`/`gap`/`border-radius`) — вторым шагом, когда шкала токенов зафиксирована (иначе гейт мешает подбору) | **триггер наступил**: витрина S1 снята, шкала `--space-1…6` заведена; включать, когда S2 обкатает её на настоящей оболочке | STACK_DECISIONS §3 | +| Ф-8 | **Вопрос владельцу: закрывать ли `/* eslint-disable */` в TSX.** CSS-половина гейта цвета обхода комментарием больше не допускает (`reportUnscopedDisables`), TSX-половина — допускает. Закрывается одной строкой `linterOptions: { noInlineConfig: true }`, но той же строкой запрещаются легитимные точечные подавления (`react-hooks/exhaustive-deps`), которые рекомендует сам React. Выбор не технический, а владельческий | решение до S2 | адверсариальное ревью S1 | +| Ф-9 | **Хвосты гейтов после адверсариального ревью S1** (все воспроизведены исполнением, ни один не в боевом коде): именованные цвета CSS в TSX (`fill="red"`, ``) не ловятся — в CSS запрещены, асимметрия; санкционированное исключение `style={{'--x': …}}` пропускает любое значение, включая литеральный цвет · JSX-спред `
` со `style` внутри · системные цветовые ключевые слова (`Highlight`, `ButtonText`) проходят в `box-shadow`/`outline`/`caret-color` и в сокращённых записях | до S2 или в ней | адверсариальное ревью S1 | +| Ф-10 | **Хвосты структурных тестов**: `cssModules.test.ts` строит пары по имени файла (компонент, импортирующий соседний модуль, из проверки выпадает), не видит деструктуризацию `const { shell } = styles`, а `classesOf` тянет в «объявленные классы» расширения из `url(...)`. `tokens.test.ts` сверяет 23 токена из 38 и не имеет замка полноты — новый токен приезжает без гейта по построению | до S2 | адверсариальное ревью S1 | | Ф-7 | **Хвосты сведения витрины с референсом** (`FRONTEND_PLAN.md` §5.2): `+` в конце рядов вкладок — вместе с действием, которое он запускает (S2) · трактовка колонки оригинала приглушённым цветом — решение, а не замер, пересмотреть на настоящей читалке (S6) · состояния наведения витриной не проверены, снимок статичен | S2 · S6 | S1, сверка с fleet.png | | Ф-5 | **Tauri 2.x вторым шагом** — по явным триггерам (трей · глобальные горячие клавиши · распространяемый .exe · офлайн · хранилище учёток ОС); до того — установимое PWA, только браузерные API | триггеры названы | STACK_DECISIONS §6, D39.84 | | Ф-6 | **`@tanstack/react-virtual`** — в резерве, подключать только по замеру (дефолт виртуализации — RAC Virtualizer) | по замеру | STACK_DECISIONS §2 | diff --git a/frontend/docs/FRONTEND_PLAN.md b/frontend/docs/FRONTEND_PLAN.md index e77c2627..03b521fe 100644 --- a/frontend/docs/FRONTEND_PLAN.md +++ b/frontend/docs/FRONTEND_PLAN.md @@ -126,7 +126,7 @@ CJK-шрифт не ставим: иероглифы отдаём системн | # | Правило | Проверка | |---|---|---| | 1 | Цвет и размер только из `tokens.css` | **машинно**: stylelint `declaration-property-value-allowed-list` (`/^var\(--/` на `color`, `background-color`, `border-color`, `fill`, `stroke`, `font-size`, `z-index`) + ESLint `no-restricted-syntax` на `#hex`/`rgb(`/`hsl(`/`oklch(` в TSX. Исключение по пути — только `tokens/` | -| 2 | Отключить гейт комментарием нельзя | **машинно**: `reportDisables: true` в конфиге stylelint — `/* stylelint-disable */` сам становится ошибкой | +| 2 | Отключить CSS-гейт комментарием нельзя | **машинно**: `reportDisables: true` на правилах + `reportUnscopedDisables: true` в корне конфига. Одного `reportDisables` НЕ хватает: он ловит только именованное отключение, а голое `/* stylelint-disable */` в шапке снимало гейт молча — проверено. **В TSX дыра открыта:** `/* eslint-disable */` работает, `noInlineConfig` сознательно не включён (он же запретит легитимные точечные подавления `react-hooks/exhaustive-deps`). Решение владельца — `BACKLOG.md` Ф-8 | | 3 | Данные только через `src/api/` | ревью-вопрос: есть ли `fetch`/`axios`/импорт из `mock/` вне `src/api/`? Должно быть «нет» | | 4 | Файл = один компонент + свой `.module.css`, больше ~150 строк — делить | ревью глазами при лендинге пакета | | 5 | Состояние ровно в двух местах: TanStack Query (серверное), Zustand (интерфейсное) | ревью-вопрос: есть ли `useState` с копией серверных данных? Должно быть «нет» | @@ -136,6 +136,8 @@ CJK-шрифт не ставим: иероглифы отдаём системн | 9 | Комментарии — одна-две строки «почему» | ревью глазами; проектная норма | | 10 | Каждый экран открывается в изоляции: свой маршрут, своя фикстура | **машинно** косвенно: скриншот-скрипт снимает экран по URL. Не открывается по прямой ссылке — не снимется | | 11 | `styles.имяКласса` ссылается на существующий класс | **машинно**: `src/cssModules.test.ts`. Vite типизирует модуль как `{ [key: string]: string }`, поэтому опечатка даёт `className="undefined"` тихо — тайпчек и линт её пропускают. Правило заведено не впрок: на витрине такая ссылка уже нашлась | +| 12 | Имя токена в `var(--…)` и в строках кода объявлено в `tokens.css` | **машинно**: `src/tokens/tokens.test.ts`. Тот же класс тихой ошибки: stylelint проверяет только форму обёртки `var(--…)`, тайпчек видит обычную строку, браузер отдаёт пустое значение — элемент гаснет в фон, и на скриншоте это 124 пикселя из 3,9 млн. Локальные переменные, задаваемые через `style`, перечислены в тесте явным списком | +| 13 | Инлайновый стиль — только литерал объекта прямо в атрибуте, и только с ключами-CSS-переменными | **машинно**: `no-restricted-syntax` запрещает сам атрибут `style` и разрешает единственную форму. Перечислять формы записи оказалось бесполезно: из одиннадцати способов записать то же самое ловилось три. Остаточная дыра — JSX-спред `
`, где `style` приезжает внутри объекта; без типовой информации не ловится | **Контрольный вопрос владельца** (`§5.1`, применять к каждому пакету работ): добавление нового состояния главы или нового вида замечания правит **один** файл. @@ -306,11 +308,29 @@ rm -f *.deb && cd .. - **`import.meta.glob` по `*.module.css` брать с `?raw`, а не `?inline`:** `?inline` отдаёт уже скомпилированный CSS с хешированными именами (`._shell_1abc_1`), и сверять с ним имена из TSX бессмысленно. -- **Гейты и оба структурных теста проверены живым нарушением, а не заявлением:** литерал цвета - в модуле · литерал в сокращённой записи `border` · `font-size` числом · попытка отключить - правило комментарием · hex в TSX · инлайновый стиль с обычным свойством · импорт глобального - CSS мимо `main.tsx` · опечатка в имени класса. Каждый раз проверка падала, после отката — - зелено. Разрешённое исключение `style={{ '--dot': … }}` проходит. +### 5.4. Что именно проверено живым нарушением + +Формулировка «гейты проверены живым нарушением» без перечня форм — ловушка: она звучит как +машинная гарантия, а покрывает ровно те входы, которые придумал автор. Ниже полный список +проверенных форм; каждая роняла проверку, после отката дерево зелёное. + +**CSS:** hex-литерал в модуле · hex в сокращённой записи `border` · `rgb()` · `color()` +в сокращённой записи · `font-size` числом · именованное отключение правила +(`/* stylelint-disable color-no-hex */`) · **голое** `/* stylelint-disable */` в шапке файла. + +**TSX:** hex всех четырёх длин (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`) · цветовые функции +включая `color()` и `light-dark()` · одиннадцать форм инлайнового стиля (литерал, спред, +вынесенная переменная, приведение `as`, свойство объекта, вызов фабрики, тернарник, +вычисляемый ключ, JSX-спред, смешанный литерал) · импорт глобального CSS мимо `main.tsx` · +предупреждение линта при `--max-warnings 0`. + +**Структурные тесты:** опечатка в имени класса CSS Module · то же при переименованном импорте +модуля · опечатка в имени токена в CSS · опечатка в имени токена в данных TS. + +**Что НЕ ловится и остаётся известной дырой:** `/* eslint-disable */` в TSX (см. правило 2) · +JSX-спред `
` со `style` внутри объекта · именованные цвета CSS в TSX +(`fill="red"`) — в CSS они запрещены, в TSX ратифицированный список `STACK_DECISIONS.md` §3 +их не называет; строка в бэклоге. --- diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index c99b8d38..4a965f7b 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -13,25 +13,43 @@ const inlineStyle = 'Инлайновый стиль запрещён: размеры и цвета берутся из .module.css на токенах. ' + "Единственное исключение — передача CSS-переменной: style={{ '--progress': value }}."; +// Длины перечислены все четыре: #RGB, #RGBA, #RRGGBB, #RRGGBBAA. Ловить «3..8 подряд» одним +// шаблоном нельзя — граница слова не сработает внутри более длинной последовательности. +const hexLengths = [3, 4, 6, 8]; + const tokenGate = [ - { selector: 'Literal[value=/#[0-9a-fA-F]{3}\\b/]', message: colorLiteral }, - { selector: 'Literal[value=/#[0-9a-fA-F]{6}\\b/]', message: colorLiteral }, - { selector: 'Literal[value=/#[0-9a-fA-F]{8}\\b/]', message: colorLiteral }, - { selector: 'Literal[value=/\\b(rgba?|hsla?|oklch|oklab|lab|lch)\\(/]', message: colorLiteral }, - { selector: 'TemplateElement[value.raw=/#[0-9a-fA-F]{3,8}\\b/]', message: colorLiteral }, - // Ключ-идентификатор — обычное свойство (color, width); CSS-переменная синтаксически - // обязана быть строковым ключом, поэтому проходит только она. + ...hexLengths.map((n) => ({ + selector: `Literal[value=/#[0-9a-fA-F]{${n}}\\b/]`, + message: colorLiteral, + })), { - selector: "JSXAttribute[name.name='style'] ObjectExpression > Property[key.type='Identifier']", + 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'] ObjectExpression > Property[key.type='Literal'][key.value!=/^--/]", + "JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > Property[key.type='Literal'][key.value!=/^--/]", message: inlineStyle, }, { - selector: "JSXAttribute[name.name='style'] > JSXExpressionContainer > Identifier", + selector: + "JSXAttribute[name.name='style'] > JSXExpressionContainer > ObjectExpression > SpreadElement", message: inlineStyle, }, ]; diff --git a/frontend/package.json b/frontend/package.json index 0cc1d3ab..c98adcd1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "check": "prettier --check . && eslint . && stylelint \"src/**/*.css\" && tsc --noEmit && vitest run", + "check": "prettier --check . && eslint . --max-warnings 0 && stylelint \"src/**/*.css\" && tsc --noEmit && vitest run", "check:full": "npm run check && npm run build && npm run shot", "shot": "node scripts/shot.mjs" }, diff --git a/frontend/scripts/shot.mjs b/frontend/scripts/shot.mjs index 3691ae58..67e49be6 100644 --- a/frontend/scripts/shot.mjs +++ b/frontend/scripts/shot.mjs @@ -1,12 +1,13 @@ // Скриншот-цикл: собрать, поднять preview, снять PNG, положить в .shots/. // Смотреть на снимок — обязательная часть цикла: без неё код валиден, а вид случаен. // -// node scripts/shot.mjs все маршруты, 1440x900 -// node scripts/shot.mjs /showcase один маршрут -// node scripts/shot.mjs --size 1280x764 вьюпорт референса — для прямого наложения +// node scripts/shot.mjs все маршруты из KNOWN_ROUTES, 1440x900 +// node scripts/shot.mjs /showcase /reader только названные +// node scripts/shot.mjs --size 1280x764 вьюпорт референса — для прямого наложения // -// Маршруты дублируют src/routes.tsx: два списка вместо загрузчика TS в Node — сознательный +// KNOWN_ROUTES дублирует src/routes.tsx: два списка вместо загрузчика TS в Node — сознательный // выбор в пользу простоты, пополнять оба. +const KNOWN_ROUTES = ['/showcase']; import { mkdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; @@ -27,15 +28,36 @@ if (existsSync(toolingRoot)) { const { chromium } = await import('playwright'); const { build, preview } = await import('vite'); -const args = process.argv.slice(2); -const sizeIndex = args.indexOf('--size'); -const [width, height] = (sizeIndex === -1 ? '1440x900' : (args[sizeIndex + 1] ?? '')) - .split('x') - .map(Number); +// Разбираем последовательно, а не фильтром по индексу: фильтр без --size выбрасывал первый +// позиционный маршрут (sizeIndex = -1, и условие index !== 0 съедало его молча). +const requested = []; +let size = '1440x900'; +let awaitingSize = false; +for (const arg of process.argv.slice(2)) { + if (awaitingSize) { + size = arg; + awaitingSize = false; + } else if (arg === '--size') { + awaitingSize = true; + } else if (arg.startsWith('/')) { + requested.push(arg); + } else { + throw new Error(`Непонятный аргумент «${arg}». Ожидается маршрут /showcase или --size ШхВ`); + } +} +if (awaitingSize) throw new Error('У --size нет значения'); + +const [width, height] = size.split('x').map(Number); if (!width || !height) throw new Error('Ожидается --size ШИРИНАxВЫСОТА, например --size 1440x900'); -const requested = args.filter((arg, index) => arg.startsWith('/') && index !== sizeIndex + 1); -const routes = requested.length > 0 ? requested : ['/showcase']; +const routes = requested.length > 0 ? requested : KNOWN_ROUTES; +const unknown = routes.filter((route) => !KNOWN_ROUTES.includes(route)); +if (unknown.length > 0) { + // Иначе в кадр молча попадёт страница ошибки роутера, а команда вернёт успех. + throw new Error( + `Нет такого маршрута: ${unknown.join(', ')}. Известные: ${KNOWN_ROUTES.join(', ')}`, + ); +} await mkdir(shotsDir, { recursive: true }); await build({ logLevel: 'warn' }); diff --git a/frontend/src/cssModules.test.ts b/frontend/src/cssModules.test.ts index 455cd32c..e1de3380 100644 --- a/frontend/src/cssModules.test.ts +++ b/frontend/src/cssModules.test.ts @@ -24,8 +24,15 @@ const classesOf = (css: string) => ?.map((m) => m.slice(1)), ); -const usedIn = (tsx: string) => - [...tsx.matchAll(/styles(?:\.(\w+)|\[['"]([\w-]+)['"]\])/g)].map((m) => m[1] ?? m[2] ?? ''); +// Имя берём из самого импорта, а не считаем, что оно всегда `styles`: привязка к одному имени +// делала проверку слепой к `import s from './X.module.css'` — проверено, тест молча проходил. +const bindingOf = (tsx: string, cssFile: string) => + new RegExp(String.raw`import\s+(\w+)\s+from\s+['"][^'"]*${cssFile}['"]`).exec(tsx)?.[1]; + +const usedIn = (tsx: string, binding: string) => + [...tsx.matchAll(new RegExp(String.raw`\b${binding}(?:\.(\w+)|\[['"]([\w-]+)['"]\])`, 'g'))].map( + (m) => m[1] ?? m[2] ?? '', + ); describe('CSS Modules', () => { const pairs = Object.keys(components) @@ -33,8 +40,12 @@ describe('CSS Modules', () => { .filter(([, css]) => css in modules); it.each(pairs)('%s не ссылается на несуществующий класс', (tsx, css) => { + const source = components[tsx] as string; + const binding = bindingOf(source, basename(css)); + expect(binding, `${basename(css)} лежит рядом, но не импортирован`).toBeDefined(); + const defined = classesOf(modules[css] as string); - const missing = usedIn(components[tsx] as string).filter((name) => !defined.has(name)); + const missing = usedIn(source, binding as string).filter((name) => !defined.has(name)); expect(missing, `нет в ${basename(css)}`).toEqual([]); }); diff --git a/frontend/src/react-css-variables.d.ts b/frontend/src/react-css-variables.d.ts new file mode 100644 index 00000000..8b69c2d1 --- /dev/null +++ b/frontend/src/react-css-variables.d.ts @@ -0,0 +1,11 @@ +import 'react'; + +// React не знает про CSS-переменные в style, из-за чего каждый такой случай требовал +// `as CSSProperties`. Приведение мешает гейту: под ним атрибут перестаёт быть литералом +// объекта, и строгая проверка инлайновых стилей начинает ругаться на разрешённый случай. +// Одно объявление снимает приведения во всём коде. +declare module 'react' { + interface CSSProperties { + [customProperty: `--${string}`]: string | number | undefined; + } +} diff --git a/frontend/src/showcase/Showcase.module.css b/frontend/src/showcase/Showcase.module.css index 58797833..dda08117 100644 --- a/frontend/src/showcase/Showcase.module.css +++ b/frontend/src/showcase/Showcase.module.css @@ -33,7 +33,7 @@ .body { display: grid; - grid-template-columns: 320px 1fr 320px; + grid-template-columns: var(--panel-side-width) 1fr var(--panel-side-width); gap: var(--gap); min-height: 0; } @@ -116,8 +116,9 @@ outline: none; } -/* Прозрачная граница вместо отступа: подложка выделения красится по padding-box, - поэтому при шаге строки 26px пилюля выходит ровно 24px, как в референсе. */ +/* Прозрачная граница вместо отступа плюс background-clip: фон по умолчанию красится + по border-box и прозрачную границу закрашивает, поэтому клип задан явно — иначе при шаге + строки 26px пилюля выходит те же 26px, а в референсе она 24px. */ .row { display: flex; gap: var(--space-2); @@ -126,6 +127,7 @@ border-block: var(--row-fill-inset) solid transparent; padding-inline: var(--space-2); border-radius: var(--radius-control); + background-clip: padding-box; } .rowNested { @@ -208,6 +210,7 @@ border-block: var(--row-fill-inset) solid transparent; padding-inline: var(--space-2); border-radius: var(--radius-control); + background-clip: padding-box; } .bankHead { diff --git a/frontend/src/showcase/Showcase.tsx b/frontend/src/showcase/Showcase.tsx index 5f19e4f9..ab15f43a 100644 --- a/frontend/src/showcase/Showcase.tsx +++ b/frontend/src/showcase/Showcase.tsx @@ -10,7 +10,6 @@ import { Search, Settings, } from 'lucide-react'; -import type { CSSProperties } from 'react'; import styles from './Showcase.module.css'; import { bankRows, bookFacts, chapters, chapterStates, pairs } from './showcaseData'; @@ -64,9 +63,7 @@ export function Showcase() { ))} diff --git a/frontend/src/tokens/tokens.css b/frontend/src/tokens/tokens.css index 2bd14cc7..643b69c8 100644 --- a/frontend/src/tokens/tokens.css +++ b/frontend/src/tokens/tokens.css @@ -40,6 +40,7 @@ --statusbar-height: 20px; --panel-header-height: 26px; --panel-padding: 6px; + --panel-side-width: 320px; /* замер: боковые панели 8‥328 и 952‥1272 при вьюпорте 1280 */ --row-height: 26px; /* шаг строки дерева и таблицы */ --row-fill-inset: 1px; /* подложка выбранной строки 24px при шаге 26 */ diff --git a/frontend/src/tokens/tokens.test.ts b/frontend/src/tokens/tokens.test.ts index 38862268..dc2dfd1c 100644 --- a/frontend/src/tokens/tokens.test.ts +++ b/frontend/src/tokens/tokens.test.ts @@ -3,6 +3,17 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import reset from './reset.css?inline'; import tokens from './tokens.css?inline'; +const styleSheets = import.meta.glob('../**/*.css', { + query: '?raw', + eager: true, + import: 'default', +}); +const sources = import.meta.glob('../**/*.{ts,tsx}', { + query: '?raw', + eager: true, + import: 'default', +}); + // Контракт-тест токенов: заменяет визуальный гейт с эталонными скриншотами, который отложен // как флейкующий между платформами (STACK_DECISIONS §3). Числа справа — замер // references/fleet.png, способ и таблица в docs/FRONTEND_PLAN.md §5.1. @@ -53,4 +64,27 @@ describe('tokens.css', () => { expect(reset.indexOf('@layer reset, vendor;')).toBeGreaterThanOrEqual(0); expect(reset.indexOf('@layer reset, vendor;')).toBeLessThan(reset.indexOf('@layer reset {')); }); + + // Опечатка в ИМЕНИ токена не ловилась ничем: stylelint проверяет только форму обёртки + // var(--…), тайпчек видит обычную строку, а браузер молча отдаёт пустое значение — + // индикатор просто гаснет в фон. Тот же класс тихой ошибки, что и опечатка в имени класса. + it('каждый var(--…) и каждое имя токена в коде объявлены в tokens.css', () => { + const declared = new Set( + tokens.match(/^\s*(--[\w-]+)\s*:/gm)?.map((m) => m.trim().slice(0, -1)), + ); + // Задаются на элементе через style, а не в tokens.css; список исчерпывающий и явный. + const local = new Set(['--dot']); + + const used = new Set(); + for (const source of Object.values(styleSheets)) { + for (const m of (source as string).matchAll(/var\(\s*(--[\w-]+)/g)) used.add(m[1] as string); + } + for (const source of Object.values(sources)) { + for (const m of (source as string).matchAll(/['"`](--[\w-]+)['"`]/g)) + used.add(m[1] as string); + } + + const dangling = [...used].filter((name) => !declared.has(name) && !local.has(name)).sort(); + expect(dangling, 'имя токена нигде не объявлено').toEqual([]); + }); }); diff --git a/frontend/stylelint.config.js b/frontend/stylelint.config.js index a75996cf..f4274353 100644 --- a/frontend/stylelint.config.js +++ b/frontend/stylelint.config.js @@ -11,11 +11,19 @@ const colorValues = [token, 'inherit', 'currentColor', 'transparent']; export default { extends: ['stylelint-config-standard'], + // reportDisables на правиле ловит только ИМЕНОВАННОЕ отключение + // (`/* stylelint-disable color-no-hex */`). Голое `/* stylelint-disable */` в шапке файла + // снимало гейт целиком и молча — проверено. Эта опция требует, чтобы отключение всегда + // называло правило, и тем самым закрывает обход. + reportUnscopedDisables: true, rules: { 'color-no-hex': [true, gate], 'color-named': ['never', gate], + // color() и light-dark() тоже задают цвет литералом, а в сокращённой записи + // (`border: 1px solid color(...)`) их не ловит ничто другое. color-mix() оставлен: + // смешивать ему нечего, кроме токенов. 'function-disallowed-list': [ - ['rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch'], + ['rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color', 'light-dark'], gate, ], 'declaration-property-value-allowed-list': [