mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
chore: update lint rules
This commit is contained in:
+4
-4
@@ -57,10 +57,10 @@
|
|||||||
|
|
||||||
- registry нового UI содержит 122 инструмента;
|
- registry нового UI содержит 122 инструмента;
|
||||||
- `pnpm --dir web check` проходит без ошибок и предупреждений;
|
- `pnpm --dir web check` проходит без ошибок и предупреждений;
|
||||||
- `pnpm --dir web lint:all` завершается с 0 errors, но с 188 warnings
|
- `pnpm --dir web lint:all` завершается с 0 errors и 0 i18n warnings; остаются 4
|
||||||
`i18n/dict-consistency` и 4 unused design tokens;
|
unused design tokens от token audit;
|
||||||
- unit/e2e результаты считать актуальными только после отдельного прогона;
|
- `pnpm verify` проходит; unit/e2e результаты считать актуальными только после
|
||||||
исторические числа в `docs/archive/` не являются baseline.
|
отдельного прогона, исторические числа в `docs/archive/` не являются baseline.
|
||||||
|
|
||||||
Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`.
|
Baseline и следующий шаг поддерживаются в `docs/plan-codebase-quality.md`.
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -5,8 +5,8 @@
|
|||||||
- [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
|
- [x] Кросс-языковой линтер словарей (warn-only: паритет ключей, пустые
|
||||||
значения, совпадение `{placeholder}`-переменных между всеми локалями) —
|
значения, совпадение `{placeholder}`-переменных между всеми локалями) —
|
||||||
правило `i18n/dict-consistency`, детали в §Фаза 8
|
правило `i18n/dict-consistency`, детали в §Фаза 8
|
||||||
`docs/plan-preview-i18n.md`. Правило реализовано; доведение baseline до
|
`docs/plan-preview-i18n.md`. Правило реализовано; Q2 перевёл i18n baseline
|
||||||
zero-warning вынесено в Q2 `docs/plan-codebase-quality.md`.
|
к zero-warning через policy-aware fallback, не меняя `/v1`.
|
||||||
- [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть
|
- [ ] проверка дублирования кода html - div с одинаковым классом и т.д. (глянуть
|
||||||
что за `SonarLint` или написать кастомный линтер)
|
что за `SonarLint` или написать кастомный линтер)
|
||||||
- [ ] проверка текстов в svelte\ts\html - всё тексты должны быть через i18n
|
- [ ] проверка текстов в svelte\ts\html - всё тексты должны быть через i18n
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
- [ ] консистентность комментариев - если есть описание к одному инструменту -
|
- [ ] консистентность комментариев - если есть описание к одному инструменту -
|
||||||
оно должно быть у всех или объяснено, почему этот инструмент такой
|
оно должно быть у всех или объяснено, почему этот инструмент такой
|
||||||
особенный и заслуживает комментарий
|
особенный и заслуживает комментарий
|
||||||
|
- [ ] удалить legacy `tools.*.params` из root i18n после проверки потребителей;
|
||||||
|
до этого Q2 держит pattern-based policy-исключение
|
||||||
|
|
||||||
## Мелочи всякие
|
## Мелочи всякие
|
||||||
|
|
||||||
|
|||||||
@@ -112,21 +112,22 @@ pnpm --dir web test:e2e
|
|||||||
|
|
||||||
### Q2. Политика i18n и zero-warning baseline
|
### Q2. Политика i18n и zero-warning baseline
|
||||||
|
|
||||||
**Оценка:** M. **Зависимости:** Q0, Q1.
|
**Статус:** готов к ревью. **Проверено:** i18n lint даёт 0 warnings, rule-тесты
|
||||||
|
и полный `pnpm verify` проходят. **Оценка:** M. **Зависимости:** Q0, Q1.
|
||||||
|
|
||||||
- Решить, являются ли `registry.title/description` физическими ключами словарей
|
- `BASE_LOCALE` — единственный источник registry-English fallback для
|
||||||
или допустимым английским fallback.
|
`tools.*.title` и `tools.*.description`; остальные локали требуют физические
|
||||||
- Не исправлять расхождения массовым allowlist-ом. Сначала классифицировать их
|
ключи.
|
||||||
на реальные пропуски и осознанные fallback-источники.
|
- Legacy `tools.*.params` исключён pattern-based policy до отдельной очистки
|
||||||
- Дополнить словари до согласованной схемы, включая реальные пропуски в `ru.ts`.
|
мёртвого словаря; пустые значения и placeholders не подавляются.
|
||||||
- После очистки включить zero-warning gate.
|
- Исправлены три реальных RU option-label gaps и удалены неиспользуемые legacy
|
||||||
- Отдельно проверить, что изменения в i18n не требуют синхронного изменения
|
option-группы из `ru.ts`.
|
||||||
`v1`-словарей: новая и legacy-ветки изолированы.
|
- Исправлен tie-break placeholder parity: при равенстве голосов выбирается
|
||||||
- Не начинать plural-формы до решения модели `string | PluralForms`
|
`BASE_LOCALE`.
|
||||||
(`docs/plan-preview-i18n.md:289-307`).
|
- `lint-all` запускает ESLint с `--max-warnings=0`; token audit пока оставляет 4
|
||||||
|
unused-token warnings отдельным debt.
|
||||||
**Готово, когда:** dictionary linter даёт ноль несогласованностей без
|
- Plural-формы остаются отдельной задачей после Q2 и не смешиваются с этим
|
||||||
молчаливого подавления ошибок, а тесты словарей проходят в обеих локалях.
|
этапом.
|
||||||
|
|
||||||
### Q3. Тестовый контракт e2e
|
### Q3. Тестовый контракт e2e
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# План: i18n в preview (новый UI)
|
# План: i18n в preview (новый UI)
|
||||||
|
|
||||||
> Статус: **частично выполнен** — базовая i18n-инфраструктура нового UI,
|
> Статус: **частично выполнен** — базовая i18n-инфраструктура нового UI,
|
||||||
> локализация ошибок и `i18n/dict-consistency` уже реализованы; zero-warning
|
> локализация ошибок, `i18n/dict-consistency` и zero-warning baseline Q2
|
||||||
> baseline и plural-формы остаются Q2. Актуальные решения и ограничения — в
|
> реализованы; plural-формы остаются отдельной задачей. Актуальные решения и
|
||||||
> `docs/plan-codebase-quality.md`.
|
> ограничения — в `docs/plan-codebase-quality.md`.
|
||||||
|
|
||||||
## 1. Что получается
|
## 1. Что получается
|
||||||
|
|
||||||
@@ -279,11 +279,11 @@
|
|||||||
`empty value "x.y" in en`.
|
`empty value "x.y" in en`.
|
||||||
- **Паритет плейсхолдеров**: у одинакового ключа набор `{name}` одинаковый во
|
- **Паритет плейсхолдеров**: у одинакового ключа набор `{name}` одинаковый во
|
||||||
всех локалях — поймать потерянную при переводе переменную.
|
всех локалях — поймать потерянную при переводе переменную.
|
||||||
- **8.3** Цель — zero-warn. Сейчас `en.ts` неполный (в `tools` только
|
- **8.3** Политика источников: `tools.*.title` и `tools.*.description` в
|
||||||
результаты-помощники, остальной английский — из реестра), поэтому после
|
`BASE_LOCALE` намеренно берутся из registry English fallback; остальные локали
|
||||||
включения будет много warn'ов: доводим `en.ts` до полноты `ru.ts` (секции +
|
обязаны иметь физические переводы. Старый `tools.*.params` не используется
|
||||||
`tools` полностью, source — registry-English, без дрейфа). До достижения цели
|
новым UI и пока исключён policy-pattern, а не списком id. Target — zero-warn
|
||||||
допускается allowlist-механизм в ядре правила для осознанных отклонений.
|
без молчаливых подавлений.
|
||||||
- **8.4** Тесты правила — фикстуры в `web/eslint-plugins/__fixtures__/`, док в
|
- **8.4** Тесты правила — фикстуры в `web/eslint-plugins/__fixtures__/`, док в
|
||||||
`web/eslint-plugins/README.md`; включение в `eslint.config.js` как `"warn"`.
|
`web/eslint-plugins/README.md`; включение в `eslint.config.js` как `"warn"`.
|
||||||
|
|
||||||
|
|||||||
@@ -50,12 +50,15 @@ baseline; после закрытия известных ошибок его м
|
|||||||
## Errors и warnings
|
## Errors и warnings
|
||||||
|
|
||||||
- `errors` завершают соответствующий инструмент ненулевым кодом.
|
- `errors` завершают соответствующий инструмент ненулевым кодом.
|
||||||
- `warnings` не завершают текущий lint автоматически, пока не включён строгий
|
- После Q2 ESLint запускается с `--max-warnings=0`: новые ESLint warnings
|
||||||
режим.
|
блокируют gate.
|
||||||
- На baseline Q0 `lint:all` даёт 0 errors, 188 `i18n/dict-consistency` warnings
|
- Token audit пока оставляет unused-token warnings отдельным неблокирующим
|
||||||
и 4 unused-token warnings. Это известные долги, а не чистый результат.
|
сообщением.
|
||||||
- После Q2 parity-линтер должен перейти к zero-warning, после чего можно
|
- `lint:all` по-прежнему сообщает 4 unused-token warnings отдельным
|
||||||
включить `--max-warnings=0` без обходных allowlist-ов.
|
предупреждением token audit; это следующий debt Q6.
|
||||||
|
- `dict-consistency` использует base-locale fallback для registry metadata и
|
||||||
|
pattern-based policy для legacy `tools.*.params`; это не молчаливый
|
||||||
|
`allowPaths`-список.
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
|
|||||||
@@ -80,14 +80,21 @@
|
|||||||
пропавшая целая секция репортится один раз (не по каждому потомку);
|
пропавшая целая секция репортится один раз (не по каждому потомку);
|
||||||
- **пустые значения**: `""` и строка из одних пробелов;
|
- **пустые значения**: `""` и строка из одних пробелов;
|
||||||
- **паритет плейсхолдеров**: у одинакового ключа набор `{name}` сравнивается с
|
- **паритет плейсхолдеров**: у одинакового ключа набор `{name}` сравнивается с
|
||||||
каноническим (мажоритарный по локалям, при равенстве — первый по `LOCALES`)
|
каноническим (мажоритарный по локалям, при равенстве — `BASE_LOCALE`, затем
|
||||||
— ловит потерянную при переводе переменную ровно один раз, даже когда
|
`LOCALES`) — ловит потерянную при переводе переменную ровно один раз, даже
|
||||||
отклоняется одна-единственная локаль.
|
когда отклоняется одна-единственная локаль.
|
||||||
- Словари-соседи читаются с диска и кэшируются на процесс линта (как
|
- Словари-соседи читаются с диска и кэшируются на процесс линта (как
|
||||||
`no-undefined-in-svelte`); каждый парсится `@typescript-eslint/parser`
|
`no-undefined-in-svelte`); каждый парсится `@typescript-eslint/parser`
|
||||||
(синтакс, без типов).
|
(синтакс, без типов).
|
||||||
- Опция `allowPaths: string[]` — dot-path ключи, исключаемые из всех проверок
|
- Опция `allowPaths: string[]` — dot-path ключи, исключаемые из всех проверок
|
||||||
(осознанные отклонения до достижения zero-warn).
|
(осознанные отклонения до достижения 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`, фикстуры-словари в
|
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
|
||||||
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
|
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
|
||||||
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
|
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
|
||||||
|
|||||||
@@ -58,6 +58,70 @@ describe("i18n/dict-consistency", () => {
|
|||||||
expect(messageIds(lint(fixture("de.ts"), "lib/i18n/de.ts"))).toEqual([]);
|
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", () => {
|
it("flags a leaf key missing in the current file", () => {
|
||||||
const enMissing = fixture("en.ts").replace(
|
const enMissing = fixture("en.ts").replace(
|
||||||
'\t\trestoreLast: "Restore {title}",',
|
'\t\trestoreLast: "Restore {title}",',
|
||||||
|
|||||||
@@ -13,16 +13,17 @@
|
|||||||
* section is reported once, not per descendant key);
|
* section is reported once, not per descendant key);
|
||||||
* - empty values: `""` or whitespace-only values;
|
* - empty values: `""` or whitespace-only values;
|
||||||
* - placeholders: for a shared key the `{name}` set is compared against the
|
* - placeholders: for a shared key the `{name}` set is compared against the
|
||||||
* canonical set (the one shared by most locales, ties broken by LOCALES
|
* canonical set (the one shared by most locales, ties broken by BASE_LOCALE
|
||||||
* order) — catches a variable lost in translation exactly once, even when
|
* then LOCALES order) — catches a variable lost in translation exactly once,
|
||||||
* only one locale deviates.
|
* even when only one locale deviates.
|
||||||
*
|
*
|
||||||
* The rule self-filters: files whose basename is not one of LOCALES are
|
* 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.
|
* ignored, so it can be attached to the whole `lib/i18n/` directory.
|
||||||
*
|
*
|
||||||
* Options (object, all optional):
|
* Options (object, all optional):
|
||||||
* - allowPaths: dot-path keys excluded from every check (conscious
|
* - allowPaths: dot-path keys excluded from every check.
|
||||||
* deviations until the dict reaches zero-warn, see Фаза 8 §8.3).
|
* - baseLocaleFallback: patterns whose missing keys are allowed in BASE_LOCALE.
|
||||||
|
* - ignoreMissingPatterns: legacy patterns excluded from missing-key checks.
|
||||||
*/
|
*/
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -70,6 +71,22 @@ function findDictExport(ast) {
|
|||||||
return null;
|
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. */
|
/** The `LOCALES` list exported from `dict.ts`, or null. */
|
||||||
function findLocalesList(ast) {
|
function findLocalesList(ast) {
|
||||||
for (const node of ast.body) {
|
for (const node of ast.body) {
|
||||||
@@ -167,6 +184,17 @@ function formatSet(set) {
|
|||||||
return `{${[...set].sort().join(", ")}}`;
|
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`). */
|
/** Ancestor paths of a dot-path (e.g. `a.b` and `a` for `a.b.c`). */
|
||||||
function* ancestorPaths(key) {
|
function* ancestorPaths(key) {
|
||||||
let i = key.lastIndexOf(".");
|
let i = key.lastIndexOf(".");
|
||||||
@@ -190,6 +218,7 @@ function readDir(dir) {
|
|||||||
if (!fs.existsSync(dictPath)) return null;
|
if (!fs.existsSync(dictPath)) return null;
|
||||||
const ast = parseTs(fs.readFileSync(dictPath, "utf8"), dictPath);
|
const ast = parseTs(fs.readFileSync(dictPath, "utf8"), dictPath);
|
||||||
const locales = findLocalesList(ast);
|
const locales = findLocalesList(ast);
|
||||||
|
const baseLocale = findBaseLocale(ast);
|
||||||
if (!locales || locales.length === 0) return null;
|
if (!locales || locales.length === 0) return null;
|
||||||
|
|
||||||
const dicts = [];
|
const dicts = [];
|
||||||
@@ -218,31 +247,42 @@ function readDir(dir) {
|
|||||||
unionPaths.add(key);
|
unionPaths.add(key);
|
||||||
if (entry.kind === STRING && (entry.value ?? "").trim() !== "") {
|
if (entry.kind === STRING && (entry.value ?? "").trim() !== "") {
|
||||||
if (!stringLeaves.has(key)) stringLeaves.set(key, []);
|
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;
|
// 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();
|
const canonicalSets = new Map();
|
||||||
for (const [key, sets] of stringLeaves) {
|
for (const [key, entries] of stringLeaves) {
|
||||||
const counts = new Map();
|
const counts = new Map();
|
||||||
for (const set of sets) {
|
for (const { set } of entries) {
|
||||||
const sig = [...set].sort().join("\u0000");
|
const sig = [...set].sort().join("\u0000");
|
||||||
counts.set(sig, (counts.get(sig) || 0) + 1);
|
counts.set(sig, (counts.get(sig) || 0) + 1);
|
||||||
}
|
}
|
||||||
let bestCount = 0;
|
let bestCount = 0;
|
||||||
for (const set of sets) {
|
let bestLocale = null;
|
||||||
|
for (const { locale, set } of entries) {
|
||||||
const sig = [...set].sort().join("\u0000");
|
const sig = [...set].sort().join("\u0000");
|
||||||
if (counts.get(sig) > bestCount) {
|
const count = counts.get(sig);
|
||||||
bestCount = counts.get(sig);
|
if (
|
||||||
|
count > bestCount ||
|
||||||
|
(count === bestCount &&
|
||||||
|
locale === baseLocale &&
|
||||||
|
bestLocale !== baseLocale)
|
||||||
|
) {
|
||||||
|
bestCount = count;
|
||||||
|
bestLocale = locale;
|
||||||
canonicalSets.set(key, set);
|
canonicalSets.set(key, set);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { locales, dicts, unionPaths, canonicalSets };
|
return { locales, baseLocale, dicts, unionPaths, canonicalSets };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -261,6 +301,14 @@ export default {
|
|||||||
type: "array",
|
type: "array",
|
||||||
items: { type: "string" },
|
items: { type: "string" },
|
||||||
},
|
},
|
||||||
|
baseLocaleFallback: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
},
|
||||||
|
ignoreMissingPatterns: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
@@ -275,6 +323,8 @@ export default {
|
|||||||
create(context) {
|
create(context) {
|
||||||
const options = context.options[0] || {};
|
const options = context.options[0] || {};
|
||||||
const allowPaths = new Set(options.allowPaths || []);
|
const allowPaths = new Set(options.allowPaths || []);
|
||||||
|
const baseLocaleFallback = options.baseLocaleFallback || [];
|
||||||
|
const ignoreMissingPatterns = options.ignoreMissingPatterns || [];
|
||||||
|
|
||||||
const filename = path.resolve(
|
const filename = path.resolve(
|
||||||
context.filename || context.physicalFilename || "",
|
context.filename || context.physicalFilename || "",
|
||||||
@@ -287,16 +337,36 @@ export default {
|
|||||||
if (!dictExport) return {};
|
if (!dictExport) return {};
|
||||||
|
|
||||||
const own = buildKeyMap(dictExport.object);
|
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.
|
const missing = [...dirInfo.unionPaths].filter((key) => !own.has(key));
|
||||||
// Top-level gaps only — a missing ancestor already covers its subtree.
|
const requiredMissing = missing.filter((key) => {
|
||||||
const missing = [...dirInfo.unionPaths].filter((k) => !own.has(k));
|
if (isMissingAllowed(key)) return false;
|
||||||
const missingSet = new Set(missing);
|
const descendants = missing.filter((candidate) =>
|
||||||
const topMissing = missing.filter(
|
candidate.startsWith(`${key}.`),
|
||||||
(k) => ![...ancestorPaths(k)].some((a) => missingSet.has(a)),
|
);
|
||||||
|
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) {
|
for (const key of topMissing) {
|
||||||
if (allowPaths.has(key)) continue;
|
|
||||||
context.report({
|
context.report({
|
||||||
node: dictExport.id,
|
node: dictExport.id,
|
||||||
messageId: "missingKey",
|
messageId: "missingKey",
|
||||||
|
|||||||
+10
-4
@@ -175,16 +175,22 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency).
|
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency).
|
||||||
// warn-only: пропущенный перевод не должен валить сборку. Список локалей
|
// Правило сообщает warning, а `lint:all` превращает новые warnings в
|
||||||
// берётся из LOCALES в lib/i18n/dict.ts — новые локали подхватываются
|
// blocking через --max-warnings=0. Список локалей берётся из LOCALES в
|
||||||
// автоматически; правило игнорирует не-локали в этой папке само.
|
// lib/i18n/dict.ts; новые локали подхватываются автоматически.
|
||||||
{
|
{
|
||||||
files: ["**/src/lib/i18n/*.ts"],
|
files: ["**/src/lib/i18n/*.ts"],
|
||||||
plugins: {
|
plugins: {
|
||||||
i18n: i18nPlugin,
|
i18n: i18nPlugin,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"i18n/dict-consistency": "warn",
|
"i18n/dict-consistency": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
baseLocaleFallback: ["tools.*.title", "tools.*.description"],
|
||||||
|
ignoreMissingPatterns: ["tools.*.params"],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Полные recommended-наборы — только на новый код.
|
// Полные recommended-наборы — только на новый код.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const bins = {
|
|||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
name: "ESLint (svelte) + design-tokens rules",
|
name: "ESLint (svelte) + design-tokens rules",
|
||||||
args: [bins.eslint, ...colorFlag, "."],
|
args: [bins.eslint, ...colorFlag, "--max-warnings=0", "."],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Stylelint (css) — design-token style",
|
name: "Stylelint (css) — design-token style",
|
||||||
|
|||||||
+3
-53
@@ -724,9 +724,6 @@ export const ru: Dict = {
|
|||||||
toColor: "Цвет конца",
|
toColor: "Цвет конца",
|
||||||
direction: "Направление",
|
direction: "Направление",
|
||||||
},
|
},
|
||||||
options: {
|
|
||||||
direction: { horizontal: "По горизонтали", vertical: "По вертикали" },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"color-wheel-png": {
|
"color-wheel-png": {
|
||||||
title: "Цветовой круг PNG",
|
title: "Цветовой круг PNG",
|
||||||
@@ -1051,13 +1048,6 @@ export const ru: Dict = {
|
|||||||
backgroundColor: "Цвет фона",
|
backgroundColor: "Цвет фона",
|
||||||
padding: "Отступ, px",
|
padding: "Отступ, px",
|
||||||
},
|
},
|
||||||
options: {
|
|
||||||
font: {
|
|
||||||
sans: "Без засечек",
|
|
||||||
serif: "С засечками",
|
|
||||||
mono: "Моноширинный",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"emoji-to-png": {
|
"emoji-to-png": {
|
||||||
title: "Эмодзи в PNG",
|
title: "Эмодзи в PNG",
|
||||||
@@ -1135,8 +1125,11 @@ export const ru: Dict = {
|
|||||||
"8": "8",
|
"8": "8",
|
||||||
"16": "16",
|
"16": "16",
|
||||||
"32": "32",
|
"32": "32",
|
||||||
|
"44": "44 (сильный)",
|
||||||
"64": "64",
|
"64": "64",
|
||||||
|
"96": "96 (сбалансированный)",
|
||||||
"128": "128",
|
"128": "128",
|
||||||
|
"192": "192 (лёгкий)",
|
||||||
"256": "256",
|
"256": "256",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1269,13 +1262,6 @@ export const ru: Dict = {
|
|||||||
font: "Шрифт",
|
font: "Шрифт",
|
||||||
bold: "Жирный",
|
bold: "Жирный",
|
||||||
},
|
},
|
||||||
options: {
|
|
||||||
font: {
|
|
||||||
sans: "Без засечек",
|
|
||||||
serif: "С засечками",
|
|
||||||
mono: "Моноширинный",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"add-text-png": {
|
"add-text-png": {
|
||||||
title: "Надпись на PNG",
|
title: "Надпись на PNG",
|
||||||
@@ -1293,24 +1279,6 @@ export const ru: Dict = {
|
|||||||
plateColor: "Цвет подложки",
|
plateColor: "Цвет подложки",
|
||||||
plateOpacity: "Прозрачность плашки, %",
|
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": {
|
"date-stamp-png": {
|
||||||
title: "Дата-штамп PNG",
|
title: "Дата-штамп PNG",
|
||||||
@@ -1328,24 +1296,6 @@ export const ru: Dict = {
|
|||||||
plateColor: "Цвет подложки",
|
plateColor: "Цвет подложки",
|
||||||
plateOpacity: "Прозрачность плашки, %",
|
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": {
|
"skew-png": {
|
||||||
title: "Наклонить PNG",
|
title: "Наклонить PNG",
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { TOOLS } from "../registry";
|
import { TOOLS } from "../registry";
|
||||||
import { setLocale } from "./locale.svelte";
|
import { setLocale } from "./locale.svelte";
|
||||||
import { searchTools, verdictText } from "./schema-tool-strings";
|
import {
|
||||||
|
searchTools,
|
||||||
|
toolDescription,
|
||||||
|
toolTitle,
|
||||||
|
verdictText,
|
||||||
|
} from "./schema-tool-strings";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setLocale("ru");
|
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)", () => {
|
describe("verdictText (вердикт с vars)", () => {
|
||||||
it("интерполирует {vars} в обоих локалях", () => {
|
it("интерполирует {vars} в обоих локалях", () => {
|
||||||
setLocale("ru");
|
setLocale("ru");
|
||||||
|
|||||||
@@ -15,6 +15,22 @@ function toolKeys(tool: ToolEntry): { fields: string[]; groups: string[] } {
|
|||||||
return { fields, groups };
|
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", () => {
|
describe("полнота словарей для нового registry", () => {
|
||||||
it("у каждого инструмента есть перевод ru с непустыми title/description", () => {
|
it("у каждого инструмента есть перевод ru с непустыми title/description", () => {
|
||||||
for (const tool of TOOLS) {
|
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("в словарях нет лишних инструментов", () => {
|
it("в словарях нет лишних инструментов", () => {
|
||||||
const ids = new Set(TOOLS.map((tool) => tool.id));
|
const ids = new Set(TOOLS.map((tool) => tool.id));
|
||||||
for (const dict of [ru.tools, en.tools]) {
|
for (const dict of [ru.tools, en.tools]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user