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
This commit is contained in:
parent
f278b5966b
commit
d8437d69dd
12 changed files with 167 additions and 39 deletions
|
|
@ -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"`, `<Icon color="dodgerblue"/>`) не ловятся — в CSS запрещены, асимметрия; санкционированное исключение `style={{'--x': …}}` пропускает любое значение, включая литеральный цвет · JSX-спред `<div {...props}/>` со `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 |
|
||||
|
|
|
|||
|
|
@ -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-спред `<div {...props} />`, где `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-спред `<div {...props} />` со `style` внутри объекта · именованные цвета CSS в TSX
|
||||
(`fill="red"`) — в CSS они запрещены, в TSX ратифицированный список `STACK_DECISIONS.md` §3
|
||||
их не называет; строка в бэклоге.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
||||
|
|
|
|||
11
frontend/src/react-css-variables.d.ts
vendored
Normal file
11
frontend/src/react-css-variables.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import 'react';
|
||||
|
||||
// React не знает про CSS-переменные в style, из-за чего каждый такой случай требовал
|
||||
// `as CSSProperties`. Приведение мешает гейту: под ним атрибут перестаёт быть литералом
|
||||
// объекта, и строгая проверка инлайновых стилей начинает ругаться на разрешённый случай.
|
||||
// Одно объявление снимает приведения во всём коде.
|
||||
declare module 'react' {
|
||||
interface CSSProperties {
|
||||
[customProperty: `--${string}`]: string | number | undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<span
|
||||
className={styles.dot}
|
||||
title={chapterStates[chapter.state].label}
|
||||
style={
|
||||
{ '--dot': `var(${chapterStates[chapter.state].colorToken})` } as CSSProperties
|
||||
}
|
||||
style={{ '--dot': `var(${chapterStates[chapter.state].colorToken})` }}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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': [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue