chore: update lint rules

This commit is contained in:
2026-09-25 15:52:30 +05:00
parent ebb2176a2c
commit c1d2957ba5
13 changed files with 268 additions and 116 deletions
+10 -3
View File
@@ -80,14 +80,21 @@
пропавшая целая секция репортится один раз (не по каждому потомку);
- **пустые значения**: `""` и строка из одних пробелов;
- **паритет плейсхолдеров**: у одинакового ключа набор `{name}` сравнивается с
каноническим (мажоритарный по локалям, при равенстве — первый по `LOCALES`)
— ловит потерянную при переводе переменную ровно один раз, даже когда
отклоняется одна-единственная локаль.
каноническим (мажоритарный по локалям, при равенстве — `BASE_LOCALE`, затем
`LOCALES`) — ловит потерянную при переводе переменную ровно один раз, даже
когда отклоняется одна-единственная локаль.
- Словари-соседи читаются с диска и кэшируются на процесс линта (как
`no-undefined-in-svelte`); каждый парсится `@typescript-eslint/parser`
(синтакс, без типов).
- Опция `allowPaths: string[]` — dot-path ключи, исключаемые из всех проверок
(осознанные отклонения до достижения zero-warn).
- `baseLocaleFallback: string[]` — паттерны ключей, которые допустимо
отсутствовать только в базовой локали; в текущей конфигурации это registry
metadata `tools.*.title` и `tools.*.description`. Пустые значения и
placeholders для присутствующих ключей всё равно проверяются.
- `ignoreMissingPatterns: string[]` — паттерны legacy-ключей, которые временно
не проверяются на missing; в текущей конфигурации это старый `tools.*.params`,
не используемый новым UI. Эта опция не отключает empty/placeholder checks.
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
@@ -58,6 +58,70 @@ describe("i18n/dict-consistency", () => {
expect(messageIds(lint(fixture("de.ts"), "lib/i18n/de.ts"))).toEqual([]);
});
const registryPolicy = {
baseLocaleFallback: ["tools.*.title", "tools.*.description"],
ignoreMissingPatterns: ["tools.*.params"],
};
it("allows registry metadata only in the base locale", () => {
const enMissing = fixture("en.ts")
.replace('\t\t\ttitle: "Add border",\n', "")
.replace('\t\t\tdescription: "Draws a {kind} frame.",\n', "");
expect(
messageIds(lint(enMissing, "lib/i18n/en.ts", registryPolicy)),
).toEqual([]);
const ruMissing = fixture("ru.ts").replace(
'\t\t\ttitle: "Добавить рамку",\n',
"",
);
expect(
messageIds(lint(ruMissing, "lib/i18n/ru.ts", registryPolicy)),
).toEqual(["missingKey"]);
});
it("does not warn for a missing tool object with only fallback metadata", () => {
const enMissingTool = fixture("en.ts").replace(
/\t\taddBorder: \{[\s\S]*?\n\t\t\},\n/,
"",
);
expect(
messageIds(
lint(enMissingTool, "lib/i18n/en.ts", {
...registryPolicy,
ignoreMissingPatterns: [
...registryPolicy.ignoreMissingPatterns,
"tools.*.results",
],
}),
),
).toEqual([]);
});
it("does not warn for configured legacy missing paths", () => {
const enMissingResults = fixture("en.ts").replace(
/\t\t\tresults: \{[\s\S]*?\n\t\t\t\},\n/,
"",
);
expect(
messageIds(
lint(enMissingResults, "lib/i18n/en.ts", {
ignoreMissingPatterns: ["tools.*.results"],
}),
),
).toEqual([]);
});
it("still reports empty fallback metadata", () => {
const enEmpty = fixture("en.ts").replace(
'title: "Add border",',
'title: "",',
);
expect(messageIds(lint(enEmpty, "lib/i18n/en.ts", registryPolicy))).toEqual(
["emptyValue"],
);
});
it("flags a leaf key missing in the current file", () => {
const enMissing = fixture("en.ts").replace(
'\t\trestoreLast: "Restore {title}",',
+90 -20
View File
@@ -13,16 +13,17 @@
* section is reported once, not per descendant key);
* - empty values: `""` or whitespace-only values;
* - placeholders: for a shared key the `{name}` set is compared against the
* canonical set (the one shared by most locales, ties broken by LOCALES
* order) — catches a variable lost in translation exactly once, even when
* only one locale deviates.
* canonical set (the one shared by most locales, ties broken by BASE_LOCALE
* then LOCALES order) — catches a variable lost in translation exactly once,
* even when only one locale deviates.
*
* The rule self-filters: files whose basename is not one of LOCALES are
* ignored, so it can be attached to the whole `lib/i18n/` directory.
*
* Options (object, all optional):
* - allowPaths: dot-path keys excluded from every check (conscious
* deviations until the dict reaches zero-warn, see Фаза 8 §8.3).
* - allowPaths: dot-path keys excluded from every check.
* - baseLocaleFallback: patterns whose missing keys are allowed in BASE_LOCALE.
* - ignoreMissingPatterns: legacy patterns excluded from missing-key checks.
*/
import fs from "node:fs";
import path from "node:path";
@@ -70,6 +71,22 @@ function findDictExport(ast) {
return null;
}
function findBaseLocale(ast) {
for (const node of ast.body) {
if (node.type !== "ExportNamedDeclaration") continue;
const decl = node.declaration;
if (!decl || decl.type !== "VariableDeclaration") continue;
for (const d of decl.declarations) {
if (d.id.type !== "Identifier" || d.id.name !== "BASE_LOCALE") {
continue;
}
const init = unwrap(d.init);
if (isStringLiteral(init)) return init.value;
}
}
return null;
}
/** The `LOCALES` list exported from `dict.ts`, or null. */
function findLocalesList(ast) {
for (const node of ast.body) {
@@ -167,6 +184,17 @@ function formatSet(set) {
return `{${[...set].sort().join(", ")}}`;
}
function matchesPattern(key, pattern) {
const keyParts = key.split(".");
const patternParts = pattern.split(".");
return (
keyParts.length >= patternParts.length &&
patternParts.every(
(part, index) => part === "*" || part === keyParts[index],
)
);
}
/** Ancestor paths of a dot-path (e.g. `a.b` and `a` for `a.b.c`). */
function* ancestorPaths(key) {
let i = key.lastIndexOf(".");
@@ -190,6 +218,7 @@ function readDir(dir) {
if (!fs.existsSync(dictPath)) return null;
const ast = parseTs(fs.readFileSync(dictPath, "utf8"), dictPath);
const locales = findLocalesList(ast);
const baseLocale = findBaseLocale(ast);
if (!locales || locales.length === 0) return null;
const dicts = [];
@@ -218,31 +247,42 @@ function readDir(dir) {
unionPaths.add(key);
if (entry.kind === STRING && (entry.value ?? "").trim() !== "") {
if (!stringLeaves.has(key)) stringLeaves.set(key, []);
stringLeaves.get(key).push(placeholders(entry.value));
stringLeaves.get(key).push({
locale: d.locale,
set: placeholders(entry.value),
});
}
}
}
// Canonical placeholder set per key: the set shared by the most locales;
// on a tie the first one encountered (LOCALES order) wins.
// on a tie the base locale wins before LOCALES order.
const canonicalSets = new Map();
for (const [key, sets] of stringLeaves) {
for (const [key, entries] of stringLeaves) {
const counts = new Map();
for (const set of sets) {
for (const { set } of entries) {
const sig = [...set].sort().join("\u0000");
counts.set(sig, (counts.get(sig) || 0) + 1);
}
let bestCount = 0;
for (const set of sets) {
let bestLocale = null;
for (const { locale, set } of entries) {
const sig = [...set].sort().join("\u0000");
if (counts.get(sig) > bestCount) {
bestCount = counts.get(sig);
const count = counts.get(sig);
if (
count > bestCount ||
(count === bestCount &&
locale === baseLocale &&
bestLocale !== baseLocale)
) {
bestCount = count;
bestLocale = locale;
canonicalSets.set(key, set);
}
}
}
return { locales, dicts, unionPaths, canonicalSets };
return { locales, baseLocale, dicts, unionPaths, canonicalSets };
}
export default {
@@ -261,6 +301,14 @@ export default {
type: "array",
items: { type: "string" },
},
baseLocaleFallback: {
type: "array",
items: { type: "string" },
},
ignoreMissingPatterns: {
type: "array",
items: { type: "string" },
},
},
additionalProperties: false,
},
@@ -275,6 +323,8 @@ export default {
create(context) {
const options = context.options[0] || {};
const allowPaths = new Set(options.allowPaths || []);
const baseLocaleFallback = options.baseLocaleFallback || [];
const ignoreMissingPatterns = options.ignoreMissingPatterns || [];
const filename = path.resolve(
context.filename || context.physicalFilename || "",
@@ -287,16 +337,36 @@ export default {
if (!dictExport) return {};
const own = buildKeyMap(dictExport.object);
const isMissingAllowed = (key) => {
if (allowPaths.has(key)) return true;
if (
ignoreMissingPatterns.some((pattern) => matchesPattern(key, pattern))
) {
return true;
}
return (
locale === dirInfo.baseLocale &&
baseLocaleFallback.some((pattern) => matchesPattern(key, pattern))
);
};
// Missing keys: everything the union has but the current file lacks.
// Top-level gaps only — a missing ancestor already covers its subtree.
const missing = [...dirInfo.unionPaths].filter((k) => !own.has(k));
const missingSet = new Set(missing);
const topMissing = missing.filter(
(k) => ![...ancestorPaths(k)].some((a) => missingSet.has(a)),
const missing = [...dirInfo.unionPaths].filter((key) => !own.has(key));
const requiredMissing = missing.filter((key) => {
if (isMissingAllowed(key)) return false;
const descendants = missing.filter((candidate) =>
candidate.startsWith(`${key}.`),
);
return (
descendants.length === 0 ||
descendants.some((candidate) => !isMissingAllowed(candidate))
);
});
const requiredSet = new Set(requiredMissing);
const topMissing = requiredMissing.filter(
(key) =>
![...ancestorPaths(key)].some((ancestor) => requiredSet.has(ancestor)),
);
for (const key of topMissing) {
if (allowPaths.has(key)) continue;
context.report({
node: dictExport.id,
messageId: "missingKey",
+10 -4
View File
@@ -175,16 +175,22 @@ export default tseslint.config(
},
},
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency).
// warn-only: пропущенный перевод не должен валить сборку. Список локалей
// берётся из LOCALES в lib/i18n/dict.ts — новые локали подхватываются
// автоматически; правило игнорирует не-локали в этой папке само.
// Правило сообщает warning, а `lint:all` превращает новые warnings в
// blocking через --max-warnings=0. Список локалей берётся из LOCALES в
// lib/i18n/dict.ts; новые локали подхватываются автоматически.
{
files: ["**/src/lib/i18n/*.ts"],
plugins: {
i18n: i18nPlugin,
},
rules: {
"i18n/dict-consistency": "warn",
"i18n/dict-consistency": [
"warn",
{
baseLocaleFallback: ["tools.*.title", "tools.*.description"],
ignoreMissingPatterns: ["tools.*.params"],
},
],
},
},
// Полные recommended-наборы — только на новый код.
+1 -1
View File
@@ -31,7 +31,7 @@ const bins = {
const steps = [
{
name: "ESLint (svelte) + design-tokens rules",
args: [bins.eslint, ...colorFlag, "."],
args: [bins.eslint, ...colorFlag, "--max-warnings=0", "."],
},
{
name: "Stylelint (css) — design-token style",
+3 -53
View File
@@ -724,9 +724,6 @@ export const ru: Dict = {
toColor: "Цвет конца",
direction: "Направление",
},
options: {
direction: { horizontal: "По горизонтали", vertical: "По вертикали" },
},
},
"color-wheel-png": {
title: "Цветовой круг PNG",
@@ -1051,13 +1048,6 @@ export const ru: Dict = {
backgroundColor: "Цвет фона",
padding: "Отступ, px",
},
options: {
font: {
sans: "Без засечек",
serif: "С засечками",
mono: "Моноширинный",
},
},
},
"emoji-to-png": {
title: "Эмодзи в PNG",
@@ -1135,8 +1125,11 @@ export const ru: Dict = {
"8": "8",
"16": "16",
"32": "32",
"44": "44 (сильный)",
"64": "64",
"96": "96 (сбалансированный)",
"128": "128",
"192": "192 (лёгкий)",
"256": "256",
},
},
@@ -1269,13 +1262,6 @@ export const ru: Dict = {
font: "Шрифт",
bold: "Жирный",
},
options: {
font: {
sans: "Без засечек",
serif: "С засечками",
mono: "Моноширинный",
},
},
},
"add-text-png": {
title: "Надпись на PNG",
@@ -1293,24 +1279,6 @@ export const ru: Dict = {
plateColor: "Цвет подложки",
plateOpacity: "Прозрачность плашки, %",
},
options: {
font: {
sans: "Без засечек",
serif: "С засечками",
mono: "Моноширинный",
},
position: {
"top-left": "Сверху слева",
"top-center": "Сверху по центру",
"top-right": "Сверху справа",
"middle-left": "По центру слева",
center: "По центру",
"middle-right": "По центру справа",
"bottom-left": "Снизу слева",
"bottom-center": "Снизу по центру",
"bottom-right": "Снизу справа",
},
},
},
"date-stamp-png": {
title: "Дата-штамп PNG",
@@ -1328,24 +1296,6 @@ export const ru: Dict = {
plateColor: "Цвет подложки",
plateOpacity: "Прозрачность плашки, %",
},
options: {
font: {
sans: "Без засечек",
serif: "С засечками",
mono: "Моноширинный",
},
position: {
"top-left": "Сверху слева",
"top-center": "Сверху по центру",
"top-right": "Сверху справа",
"middle-left": "По центру слева",
center: "По центру",
"middle-right": "По центру справа",
"bottom-left": "Снизу слева",
"bottom-center": "Снизу по центру",
"bottom-right": "Снизу справа",
},
},
},
"skew-png": {
title: "Наклонить PNG",
+18 -1
View File
@@ -1,7 +1,12 @@
import { afterEach, describe, expect, it } from "vitest";
import { TOOLS } from "../registry";
import { setLocale } from "./locale.svelte";
import { searchTools, verdictText } from "./schema-tool-strings";
import {
searchTools,
toolDescription,
toolTitle,
verdictText,
} from "./schema-tool-strings";
afterEach(() => {
setLocale("ru");
@@ -45,6 +50,18 @@ describe("searchTools (кросс-языковой поиск каталога)"
});
});
describe("metadata fallback", () => {
it("uses registry title and description in the base locale", () => {
setLocale("en");
for (const tool of TOOLS) {
expect(toolTitle(tool), `${tool.id}: title`).toBe(tool.title);
expect(toolDescription(tool), `${tool.id}: description`).toBe(
tool.description,
);
}
});
});
describe("verdictText (вердикт с vars)", () => {
it("интерполирует {vars} в обоих локалях", () => {
setLocale("ru");
+32
View File
@@ -15,6 +15,22 @@ function toolKeys(tool: ToolEntry): { fields: string[]; groups: string[] } {
return { fields, groups };
}
type SelectOption = { value: string; label: string };
function selectOptions(
tool: ToolEntry,
): { fieldId: string; option: SelectOption }[] {
const result: { fieldId: string; option: SelectOption }[] = [];
for (const [fieldId, field] of Object.entries(tool.schema?.fields ?? {})) {
const spec = (
field as { spec?: { kind?: string; options?: SelectOption[] } }
).spec;
if (spec?.kind !== "select") continue;
for (const option of spec.options ?? []) result.push({ fieldId, option });
}
return result;
}
describe("полнота словарей для нового registry", () => {
it("у каждого инструмента есть перевод ru с непустыми title/description", () => {
for (const tool of TOOLS) {
@@ -43,6 +59,22 @@ describe("полнота словарей для нового registry", () => {
}
});
it("все active select options переведены в en и ru", () => {
for (const tool of TOOLS) {
for (const { fieldId, option } of selectOptions(tool)) {
if (fieldId === "component") continue;
const key = `${tool.id}: ${fieldId}.${option.value}`;
expect(en.tools[tool.id]?.options?.[fieldId]?.[option.value], key).toBe(
option.label,
);
expect(
ru.tools[tool.id]?.options?.[fieldId]?.[option.value],
key,
).toBeTruthy();
}
}
});
it("в словарях нет лишних инструментов", () => {
const ids = new Set(TOOLS.map((tool) => tool.id));
for (const dict of [ru.tools, en.tools]) {