chore: update lint rules

This commit is contained in:
2026-09-26 20:10:35 +05:00
parent 67fdef4363
commit 6a44a3d018
8 changed files with 185 additions and 18 deletions
+9 -3
View File
@@ -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()`.
## Тесты кастомных линт-правил
+9 -6
View File
@@ -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",
@@ -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 [
+11
View File
@@ -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):");
+33
View File
@@ -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;
}