diff --git a/AGENTS.md b/AGENTS.md index 1fd6d68..f8c91de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,9 @@ `web/src/lib/components/`, даже «просто div с двумя стилями». - Дизайн-токены (цвета, размеры, длительности, z-index) — только из CSS-переменных; детали ограничений — в `web/eslint-plugins/README.md`. +- Новый токен появляется вместе с потребителем: `pnpm --dir web lint:tokens` + сообщает о неиспользуемых токенах, и это не блокирует CI только потому, что + проверка оставлена предупреждением. ## Документация diff --git a/docs/plan-codebase-quality.md b/docs/plan-codebase-quality.md index 9bf00b9..cee565a 100644 --- a/docs/plan-codebase-quality.md +++ b/docs/plan-codebase-quality.md @@ -452,15 +452,53 @@ file-result-инструментам. неопределённого глобала в `scripts/` дают ошибки, то есть правило применилось, а не молча пропустило файлы. -#### Q7d. Оставшиеся направления +#### Q7d. Решения по оставшимся вопросам + +**Статус:** закрыт тремя решениями. + +1. **Обычный CSS под те же токен-правила, что Svelte styles — узко.** В `src` + всего один CSS-файл, и он же словарь токенов, поэтому переносить туда + `no-hardcoded-in-svelte` и подобные нечего: stylelint уже проверяет + `src/**/*.css` (префиксы, запрет `!important`, цвет только у brand-токенов), + а `check-tokens.mjs` дублирует проверку цвета. Реальный пробел был один — + `var()` может ссылаться на несуществующий токен, — его закрыл новый модуль + `web/scripts/token-audit/refs.mjs` (см. Q7e). Широкую унификацию механизмов + решено не делать: это лишний слой поверх дублирующихся проверок. +2. **Конфиги под рекомендации — только JS.** `*.config.js` + (`eslint`/`postcss`/`stylelint`) добавлены в `toolingFiles`; по измерению они + дают 0 проблем, поэтому это бесплатно. TS-конфиги (`vite`, `vitest`, + `playwright`) не подключены: им потребовался бы `tseslint`-парсер и запись + `vite.config.ts` в `allowDefaultProject`, а выигрыш близок к нулю. Попутно + удалена мёртвая запись `svelte.config.js` в `allowDefaultProject` — такого + файла нет. +3. **Unused-аудит остаётся предупреждением.** Правило «новый токен — вместе с + потребителем» закреплено в `AGENTS.md`. Причина: `verify` входит в + deploy-workflow, поэтому стилевой долг блокировал бы деплой сайта; кроме + того, чтение токена из JS (`getPropertyValue`) аудит не видит и дал бы ложное + срабатывание. Baseline чистый, пыль вычищена в Q7a. + +#### Q7e. Проверка резолва `var()` в `app.css` + +**Статус:** выполнен. Аудит падает, если `var(--x)` в `app.css` указывает на +токен, которого нет в этом же файле. + +- `web/scripts/token-audit/refs.mjs` + вызов в `check-tokens.mjs`; режим — + ошибка, а не предупреждение, потому что это дефект (объявление молча + отбрасывается), а не стилистический долг. +- `var(--x, fallback)` не считается ошибкой: fallback легален без определения + токена. +- 7 тестов в `scripts/__tests__/token-audit.test.mjs`: резолв в обеих темах, + опечатка с локацией, fallback, дедуп повторов, ссылка внутри определения + токена, `var()` в обычном свойстве. +- Проверено на живом файле: временная опечатка `var(--color-bordr)` дала + `Unresolved var() references` и код выхода 1; `app.css` восстановлен байт в + байт. + +#### Q7f. Оставшиеся направления -- Отдельно решить, должен ли обычный CSS проходить проверку undefined tokens и - hardcoded colors наравне со Svelte styles. -- Решить, должны ли сами конфиги (`eslint.config.js`, `postcss.config.js`, - `stylelint.config.js`, `svelte.config.js`, `vite.config.ts`) тоже попасть под - рекомендации: сейчас вне `toolingFiles`. -- Решить, должен ли unused audit стать fail-режимом (см. Q7a). - Новые правила подключать только после fixtures, `test:rules` и baseline. +- Если в коде появится чтение токенов из JS, `collectUsedTokens` придётся + расширить — иначе unused-аудит начнёт считать используемые токены пылью. ### Q8. Новые автоматические правила diff --git a/docs/quality-gates.md b/docs/quality-gates.md index 8456411..9b9252b 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -80,8 +80,11 @@ per-project лимит воркеров, а browser matrix упирается в - `errors` завершают соответствующий инструмент ненулевым кодом. - После Q2 ESLint запускается с `--max-warnings=0`: новые ESLint warnings блокируют gate. -- Token audit оставляет unused-token сообщение неблокирующим: после Q7 пыль - вычищена, но решение о fail-режиме остаётся открытым. +- Token audit оставляет unused-token сообщение неблокирующим: после Q7a пыль + вычищена, решение о fail-режиме зафиксировано в Q7d — новый токен появляется + вместе с потребителем, но стиль не блокирует деплой. +- Резолв `var()` в `app.css` — ошибка: несуществующий токен молча роняет + объявление, поэтому этот check failing, в отличие от unused. - `lint:all` сейчас чист: 0 errors, 0 i18n warnings, 0 unused tokens. - `dict-consistency` использует base-locale fallback для registry metadata и pattern-based policy для временно неиспользуемых `tools.*.params`; это не diff --git a/web/eslint-plugins/README.md b/web/eslint-plugins/README.md index 89f8746..cc52e26 100644 --- a/web/eslint-plugins/README.md +++ b/web/eslint-plugins/README.md @@ -134,9 +134,15 @@ `color-mix(...)` (единственный легальный способ смешать два токена). `oklch()/rgb()/#hex` в `--color-*` запрещены. -Выводит **варнинг** о неиспользуемых токенах app.css (определены, но нигде не -используются) — выход он не меняет. Провалом (exit 1) считаются только parity и -hct-авторство. +- **Резолв `var()`**: каждая ссылка `var(--x)` в app.css должна указывать на + токен, определённый в этом же файле. `var(--x, fallback)` не считается ошибкой + — fallback легален без определения. Провал (exit 1), потому что несуществующий + токен молча роняет объявление. +- **Неиспользуемые токены**: выводится **варнинг** (определены, но нигде не + используются) — выход он не меняет. Решение оставить это неблокирующим + зафиксировано в `docs/plan-codebase-quality.md` (Q7d): правило «новый токен — + вместе с потребителем» живёт в `AGENTS.md`. Провалами (exit 1) считаются + parity, hct-авторство и резолв `var()`. ## Тесты кастомных линт-правил diff --git a/web/eslint.config.js b/web/eslint.config.js index 167750e..5c4de99 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -11,11 +11,15 @@ import i18nPlugin from "./eslint-plugins/i18n/index.js"; // Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас. const newCode = ["**/src/lib/components/**", "**/src/routes/**"]; -// Инфраструктура линтинга: сами плагины и скрипты. Это не продуктовый код, но -// он влияет на все гейты, поэтому к нему тоже применяется базовый JS -// recommended. Полные TS/svelte-наборы здесь не подключаются: файлы .mjs и -// конфиги проверяются только базовым набором. -const toolingFiles = ["**/eslint-plugins/**/*.js", "**/scripts/**/*.mjs"]; +// Инфраструктура линтинга: сами плагины, скрипты и конфиги. Это не +// продуктовый код, но всё это влияет на гейты, поэтому к нему тоже +// применяется базовый JS recommended. Полные TS/svelte-наборы здесь не +// подключаются: файлы .mjs и .js проверяются только базовым набором. +const toolingFiles = [ + "**/eslint-plugins/**/*.js", + "**/scripts/**/*.mjs", + "*.config.js", +]; // Полные recommended-наборы — только на новый код (см. ниже, блок перед prettier). const jsRecommended = Array.isArray(js.configs.recommended) @@ -59,7 +63,6 @@ export default tseslint.config( allowDefaultProject: [ "vitest.config.ts", "eslint.config.js", - "svelte.config.js", "playwright.config.ts", "e2e/helpers/fixtures.ts", "e2e/helpers/page.ts", diff --git a/web/scripts/__tests__/token-audit.test.mjs b/web/scripts/__tests__/token-audit.test.mjs index 0dbcbe0..c6dfa4a 100644 --- a/web/scripts/__tests__/token-audit.test.mjs +++ b/web/scripts/__tests__/token-audit.test.mjs @@ -7,6 +7,7 @@ import { isDerived, } from "../token-audit/helpers.mjs"; import { checkParity } from "../token-audit/parity.mjs"; +import { checkUnresolvedRefs } from "../token-audit/refs.mjs"; import { checkUnused } from "../token-audit/unused.mjs"; function themes(css) { @@ -134,6 +135,75 @@ describe("checkUnused", () => { }); }); +describe("checkUnresolvedRefs", () => { + it("accepts references to tokens defined in the same file", () => { + const { root } = themes( + ":root { --color-text: #111; .x { color: var(--color-text); } }", + ); + + expect(checkUnresolvedRefs(root)).toEqual([]); + }); + + it("accepts references defined in the other theme block", () => { + const { root } = themes( + [ + ":root { --color-text: #111; }", + '[data-theme="dark"] { --color-text: #eee; }', + ".x { color: var(--color-text); }", + ].join("\n"), + ); + + expect(checkUnresolvedRefs(root)).toEqual([]); + }); + + it("reports a typo in a token name with its location", () => { + const { root } = themes( + ":root { --color-text: #111; .x { border-color: var(--color-bordr); } }", + ); + + expect(checkUnresolvedRefs(root)).toEqual([ + " --color-bordr used in .x (border-color) is not defined in app.css", + ]); + }); + + it("exempts a reference that provides a fallback value", () => { + const { root } = themes(".x { color: var(--color-missing, #fff); }"); + + expect(checkUnresolvedRefs(root)).toEqual([]); + }); + + it("reports an undefined reference without a fallback even once", () => { + const { root } = themes( + [ + ":root { --a: 1px; --b: 1px; }", + ".x { border-color: var(--ghost); outline-color: var(--ghost); }", + ].join("\n"), + ); + + expect(checkUnresolvedRefs(root)).toEqual([ + " --ghost used in .x (border-color) is not defined in app.css", + ]); + }); + + it("checks references inside token definitions themselves", () => { + const { root } = themes( + ":root { --color-text: hct(from var(--brand-typo) h c t); }", + ); + + expect(checkUnresolvedRefs(root)).toEqual([ + " --brand-typo used in :root (--color-text) is not defined in app.css", + ]); + }); + + it("ignores non-custom properties that use var()", () => { + const { root } = themes( + ".x { --local: 1px; width: calc(var(--local) * 2); }", + ); + + expect(checkUnresolvedRefs(root)).toEqual([]); + }); +}); + describe("token predicates", () => { it("detects color values in every supported notation", () => { for (const value of [ diff --git a/web/scripts/check-tokens.mjs b/web/scripts/check-tokens.mjs index 14e465d..733b072 100644 --- a/web/scripts/check-tokens.mjs +++ b/web/scripts/check-tokens.mjs @@ -7,6 +7,7 @@ import { FILE, parsePreview } from "./token-audit/helpers.mjs"; import { checkParity } from "./token-audit/parity.mjs"; import { checkColorAuthorship } from "./token-audit/colors.mjs"; +import { checkUnresolvedRefs } from "./token-audit/refs.mjs"; import { checkUnused } from "./token-audit/unused.mjs"; async function main() { @@ -34,6 +35,16 @@ async function main() { console.log("All colors are authored as hct()."); } + const unresolved = checkUnresolvedRefs(root); + if (unresolved.length > 0) { + console.log( + `Unresolved var() references in ${FILE}:\n${unresolved.join("\n")}`, + ); + failed = true; + } else { + console.log("All var() references in app.css resolve."); + } + const unused = checkUnused(root); if (unused.length > 0) { console.log("\nUnused tokens (defined in app.css, never used):"); diff --git a/web/scripts/token-audit/refs.mjs b/web/scripts/token-audit/refs.mjs new file mode 100644 index 0000000..afe52d1 --- /dev/null +++ b/web/scripts/token-audit/refs.mjs @@ -0,0 +1,33 @@ +// Unresolved references: every var(--x) in app.css must point at a token the +// same file defines. A typo here silently drops the declaration, so unlike the +// unused-token dust it is a defect, not a style choice. +// +// `var(--x, fallback)` is legal without --x (CSS custom property fallback), so +// those references are exempt. + +const VAR_REF_RE = /var\(\s*(--[\w-]+)\s*([,)])/g; + +export function checkUnresolvedRefs(root) { + const defined = new Set(); + root.walkDecls((decl) => { + if (decl.prop.startsWith("--")) defined.add(decl.prop); + }); + + const errors = []; + const seen = new Set(); + root.walkDecls((decl) => { + for (const match of decl.value.matchAll(VAR_REF_RE)) { + const [, token, terminator] = match; + // terminator ")" after whitespace means no fallback argument + if (defined.has(token)) continue; + if (terminator === ",") continue; + if (seen.has(token)) continue; + seen.add(token); + const where = decl.parent?.selector || decl.parent?.name || ""; + errors.push( + ` ${token} used in ${where} (${decl.prop}) is not defined in app.css`, + ); + } + }); + return errors; +}