chore: add new lint rule - check hardcoded text

This commit is contained in:
2026-09-26 21:23:50 +05:00
parent 7bca9f3a3a
commit b59a434c65
9 changed files with 454 additions and 24 deletions
+3 -3
View File
@@ -57,9 +57,9 @@
- production registry содержит 122 инструмента; - production registry содержит 122 инструмента;
- `pnpm --dir web check` проходит без ошибок и предупреждений; - `pnpm --dir web check` проходит без ошибок и предупреждений;
- `pnpm --dir web test` — 45 test files / 497 tests, включая тесты token audit и - `pnpm --dir web test` — 46 test files / 534 tests, включая тесты token audit,
PostCSS HCT-плагина; PostCSS HCT-плагина и правил линтинга;
- `pnpm --dir web test:rules` — 4 test files / 74 tests; - `pnpm --dir web test:rules` — 5 test files / 104 tests;
- `pnpm --dir web lint:all` — 0 errors, 0 i18n warnings и 0 unused design tokens - `pnpm --dir web lint:all` — 0 errors, 0 i18n warnings и 0 unused design tokens
от token audit; от token audit;
- coverage `src/lib` — statements 91.77%, branches 83.3%, functions 74.31%, - coverage `src/lib` — statements 91.77%, branches 83.3%, functions 74.31%,
+32 -16
View File
@@ -476,9 +476,38 @@ file-result-инструментам.
### Q8. Новые автоматические правила ### Q8. Новые автоматические правила
**Оценка:** L. **Зависимости:** Q4, Q7. **Оценка:** L. **Зависимости:** Q4, Q7. **Разбивка:** три атомарных шага, каждый
заканчивается либо работающим warn-правилом с нулевым baseline, либо измерением
с записью вывода. Механически важно: `lint-all` запускает ESLint с
`--max-warnings=0`, поэтому warn-правило обязано приземлиться без срабатываний
на текущем коде.
#### Дублирование HTML/Svelte-разметки #### Q8.1. Пользовательские тексты вне i18n
**Статус:** выполнен. Пилот `i18n/no-hardcoded-user-text` (warn) с нулевым
baseline; 2 реальных нарушения исправлены.
- Измерение до правила: 42 строковых атрибута пользовательского текста, все — в
`/kit`; вне кита текстовые узлы дали 2 находки. Итог после исключений: 2
настоящих бага и 2 бренда, которые план требовал исключить.
- `web/eslint-plugins/i18n/no-hardcoded-user-text.js`: узлы `SvelteText` плюс
строковые литералы пользовательских атрибутов; `<style>`/`<script>`
пропускаются.
- Исключения из плана реализованы списками, а не эвристиками: форматы/единицы/
имена файлов/версии/размеры, ALL CAPS идентификаторы (registry source
strings), `/kit` через scope, бренд через опцию `allowWords` (в конфиге, а не
в коде правила).
- Исправлены 2 бага: `ColorPairControl.svelte` показывал английские `From`/`To`.
Ключи `ui.from`/`ui.to` уже существовали в обоих словарях — компонент их
просто не использовал, поэтому новые ключи не понадобились.
- 30 тестов в `__tests__/no-hardcoded-user-text.test.ts`; мутацией проверено,
что правило не декоративное: с пустым `allowWords` бренд начинает репортиться.
- Известные слепые зоны, зафиксированные как границы пилота: текст, собранный в
скрипте и переданный выражением, и текст вне Svelte-шаблонов (например, в
атрибутах, приходящих из registry). Расширение поверхности — отдельная работа,
а не молчаливое расширение правила.
#### Q8.2. Дублирование HTML/Svelte-разметки
Сначала провести report-only аудит новых `components/**` и `routes/**`. Сначала провести report-only аудит новых `components/**` и `routes/**`.
Определить: Определить:
@@ -490,20 +519,7 @@ file-result-инструментам.
Только после оценки false positives выбирать между готовым detector и Только после оценки false positives выбирать между готовым detector и
собственным AST-правилом. Не включать detector в blocking gate сразу. собственным AST-правилом. Не включать detector в blocking gate сразу.
#### Пользовательские тексты вне i18n #### Q8.3. Согласованность комментариев
Сделать warn-only пилот с исключениями для:
- бренда и версии;
- форматов и имён файлов;
- `X`/`Y` и технических значений;
- registry source strings;
- `/kit`;
- тестовых данных и внутренних сообщений.
Blanket-запрет всех string literals не использовать.
#### Согласованность комментариев
Автоматический lint не делать. Это review/process policy: комментарий должен Автоматический lint не делать. Это review/process policy: комментарий должен
объяснять причину исключения, а `TODO`/`FIXME` — ссылаться на backlog или issue объяснять причину исключения, а `TODO`/`FIXME` — ссылаться на backlog или issue
+28 -1
View File
@@ -42,6 +42,9 @@
`__fixtures__/` дописать их туда же. `__fixtures__/` дописать их туда же.
- design-tokens правила применяются к тем же scoped-путям - design-tokens правила применяются к тем же scoped-путям
(`src/lib/components/**`, `src/routes/**`). (`src/lib/components/**`, `src/routes/**`).
- `i18n/no-hardcoded-user-text` применяется к `src/lib/components/**` (`/kit`
исключён как витрина с намеренно hardcoded-подписями) и настраивается опцией
`allowWords` —brand не хардкодится в правиле.
## Правила плагина design-tokens ## Правила плагина design-tokens
@@ -60,7 +63,9 @@
(ESLint лезет в `<style>`-блоки через постпрефес postcss AST от (ESLint лезет в `<style>`-блоки через постпрефес postcss AST от
`svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.) `svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.)
## Правило плагина i18n (`i18n/dict-consistency`) ## Плагин i18n (`i18n/`)
### `i18n/dict-consistency`
Кросс-языковой линтер словарей `lib/i18n/` (историческая модель и завершённые Кросс-языковой линтер словарей `lib/i18n/` (историческая модель и завершённые
фазы: `docs/archive/plan-preview-i18n.md`). Сравнивает каждый словарь со фазы: `docs/archive/plan-preview-i18n.md`). Сравнивает каждый словарь со
@@ -97,6 +102,28 @@
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
диске. диске.
### `i18n/no-hardcoded-user-text`
Запрещает пользовательский текст, зашитый в шаблон: литерал в Svelte-тексте или
в строковом значении пользовательского атрибута молча уедет в RU-локаль. Пилот
report-only (`warn`), baseline — 0 срабатываний.
- Поверхность: узлы `SvelteText` (включая текстовые части смешанного контента) и
строковые литералы атрибутов из `i18n/lists.js` (`label`, `title`,
`placeholder`, `aria-label`, `alt`, `description`, `emptyText`, `hint`,
`eyebrow`, `suffix`, `unit`, `error`). Технические атрибуты (`variant`,
`tone`, `type`, `icon`, `size`, `href`, `class`) не проверяются, значения в
скобках (`{expr}`) тоже — они считаются в другом месте.
- Исключения списком, а не «на глаз»: форматы и единицы (`PNG`, `px`, `×`),
имена файлов и версии (`result.png`, `v0.1.0`), размеры (`512×512`),
registry-строки и прочие ALL CAPS идентификаторы (`BACKGROUND`), а также
`/kit` — витрина компонентов с намеренно латинскими подписями.
- Опция `allowWords: string[]` — brand и прочие собственные имена. Название
продукта передаётся конфигом, а не зашито в правило.
- Содержимое `<style>` и `<script>` пропускается: там код, а не копирайт.
- Тесты: `__tests__/no-hardcoded-user-text.test.ts` — `RuleTester` на
svelte-eslint-parser плюс прямые кейсы `hasUserWords`.
## Токены-префиксы (целевой словарь дизайна) ## Токены-префиксы (целевой словарь дизайна)
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две - Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
@@ -0,0 +1,167 @@
// Tests for the i18n/no-hardcoded-user-text rule
// (web/eslint-plugins/i18n/no-hardcoded-user-text.js).
//
// The rule is a pure AST check over the Svelte template, so it runs through the
// standard RuleTester with svelte-eslint-parser + the TS sub-parser. The
// exclusion list (brand, formats, units, registry source strings) is the whole
// point of the pilot, so every exclusion has a case here.
import { RuleTester } from "eslint";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import { describe, expect, it } from "vitest";
import rule, { hasUserWords } from "../i18n/no-hardcoded-user-text.js";
import { asRuleModule } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const ruleTester = new RuleTester({
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tseslint.parser },
},
});
const OPTIONS = [{ allowWords: ["easy-png-tools"] }];
ruleTester.run("no-hardcoded-user-text", asRuleModule(rule), {
valid: [
// Translated text comes from t(), never from a literal.
{ code: '<p>{t("ui.reset")}</p>', filename: "Component.svelte" },
{
code: '<Button label={t("actions.download")} />',
filename: "Component.svelte",
},
// Formats, units and file names are not copy.
{ code: "<p>PNG</p>", filename: "Component.svelte" },
{ code: "<p>result.png</p>", filename: "Component.svelte" },
{ code: "<p>512×512</p>", filename: "Component.svelte" },
{ code: '<span class="u">px</span>', filename: "Component.svelte" },
{ code: "<p>1.0 s</p>", filename: "Component.svelte" },
{ code: "<p>v0.1.0</p>", filename: "Component.svelte" },
// Registry source strings and other shouted identifiers.
{ code: "<p>BACKGROUND</p>", filename: "Component.svelte" },
{ code: '<StepCard type="TRANSFORM" />', filename: "Component.svelte" },
// Technical attributes are out of scope.
{
code: '<Button variant="outline" tone="danger" size="lg" />',
filename: "Component.svelte",
},
{
code: '<a href="/tools/resize-png">{t("nav.home")}</a>',
filename: "x.svelte",
},
// Values written as expressions are computed elsewhere.
{
code: "<p>{value}</p>",
filename: "Component.svelte",
},
{
code: "<EmptyState title={copy} description={hint} />",
filename: "Component.svelte",
},
// Brand configured as an allowed word.
{
code: "<span>easy-png-tools</span>",
filename: "Component.svelte",
options: OPTIONS,
},
{
code: "<footer><span>easy-png-tools</span><span>v0.1.0</span></footer>",
filename: "Component.svelte",
options: OPTIONS,
},
// <style> and <script> bodies are code, not copy.
{
code: '<p>{t("ui.reset")}</p>\n<style>.a { color: red; }</style>',
filename: "Component.svelte",
options: OPTIONS,
},
{
code: '<script>const hint = "internal message";</script>\n<p>{t("ui.reset")}</p>',
filename: "Component.svelte",
options: OPTIONS,
},
],
invalid: [
{
code: "<p>Visible text</p>",
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
code: '<span class="axis-label">From</span>',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
code: '<Button label="Download result" />',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
code: '<Button label="outline" variant="outline" />',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
code: '<ImageCard alt="Preview of the processed image" />',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
// Mixed content: the text part carries copy, the unit part does not.
code: "<p>Result {value} px</p>",
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
// Navigation copy is user text too.
code: '<a href="/tools/resize-png">Home</a>',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
// Nested elements are walked too.
code: "<div><span><b>Delete file</b></span></div>",
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
// Without the option the brand word is reported: the exclusion is
// configuration, not a hardcoded constant inside the rule.
code: "<span>easy-png-tools</span>",
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
],
});
describe("hasUserWords", () => {
it("accepts technical fragments", () => {
for (const value of [
"",
" ",
"px",
"512×512",
"result.png",
"1.0 s",
"v0.1.0",
"× ·",
"BACKGROUND",
"·",
]) {
expect(hasUserWords(value), value).toBe(false);
}
});
it("accepts configured brand words", () => {
expect(hasUserWords("easy-png-tools", ["easy-png-tools"])).toBe(false);
expect(hasUserWords("easy-png-tools")).toBe(true);
});
it("accepts formats and units next to real copy", () => {
expect(hasUserWords("PNG 512×512")).toBe(false);
expect(hasUserWords("Result PNG")).toBe(true);
});
});
+2
View File
@@ -8,6 +8,7 @@
* new locales are picked up automatically. * new locales are picked up automatically.
*/ */
import dictConsistency from "./dict-consistency.js"; import dictConsistency from "./dict-consistency.js";
import noHardcodedUserText from "./no-hardcoded-user-text.js";
export default { export default {
meta: { meta: {
@@ -16,5 +17,6 @@ export default {
}, },
rules: { rules: {
"dict-consistency": dictConsistency, "dict-consistency": dictConsistency,
"no-hardcoded-user-text": noHardcodedUserText,
}, },
}; };
+19
View File
@@ -0,0 +1,19 @@
// Lists shared by the i18n rules.
// Attributes whose string value is shown to a user, so a literal there is
// hardcoded copy. Technical attributes (variant, tone, type, icon, size, role,
// href, id, class, name) are deliberately absent.
export const USER_TEXT_ATTRS = new Set([
"alt",
"aria-label",
"description",
"emptyText",
"error",
"eyebrow",
"hint",
"label",
"placeholder",
"suffix",
"title",
"unit",
]);
@@ -0,0 +1,180 @@
// Rule: NO HARDCODED USER TEXT IN SVELTE TEMPLATES.
//
// Every user-visible string must come from the dictionaries via t(); a literal in
// the template silently ships English to the RU locale. Warn-only by design
// (report-only pilot), so the baseline stays green while the rule is calibrated.
//
// What is NOT reported (the pilot's exclusion list, from the plan):
// - brand and version strings ("easy-png-tools", "v0.1.0");
// - formats, file names and units ("PNG", "result.png", "512×512", "px", "%");
// - X/Y axis markers and other one-letter technical markers;
// - registry source strings (ALL CAPS identifiers such as "BACKGROUND");
// - /kit, which is a component gallery with deliberately hardcoded labels.
//
// Detection surface: SvelteText nodes (including the text parts of mixed content)
// and string-literal values of user-facing attributes. Values written as
// expressions ({...}) are not inspected — the value is computed elsewhere.
import { USER_TEXT_ATTRS } from "./lists.js";
// Formats, units and axis markers that look like words but are not copy.
const TECH_WORDS = new Set([
"png",
"webp",
"jpg",
"jpeg",
"bmp",
"gif",
"svg",
"zip",
"hct",
"oklch",
"rgb",
"rgba",
"px",
"em",
"rem",
"ms",
"s",
"x",
"y",
"kb",
"mb",
"gb",
"dpi",
"bit",
"bits",
"i18n",
"l10n",
"url",
"uuid",
"v1",
"v2",
]);
// Tokens made only of digits, separators and technical characters.
const TECHNICAL_ONLY_RE = /^[\d\s.,:;·×x/%()[\]{}+\-–—*=<>!?&#@'"`|~^$]*$/i;
// A file name, an extension, a version, a dimension pair, a size with a unit.
const TECH_TOKEN_RE =
/(\.(png|webp|jpg|jpeg|bmp|gif|svg|zip|json|ts|svelte|css|md)\b)|(\bv?\d+(\.\d+)+\b)|(\d+\s*[×x]\s*\d+)|(\d+(\.\d+)?\s*(px|em|rem|%|px|s\b))/i;
/** Collapse whitespace so mixed content like "Result px" can be judged. */
function normalize(value) {
return value.replace(/\s+/g, " ").trim();
}
/**
* True when the fragment carries a word that a user is meant to read.
*/
export function hasUserWords(raw, allowWords = []) {
const text = normalize(raw);
if (text === "") return false;
if (TECHNICAL_ONLY_RE.test(text)) return false;
// Registry source strings and other shouted identifiers are not copy.
if (/^[A-Z0-9_ -]+$/.test(text) && /[A-Z]/.test(text)) return false;
const allowed = new Set(allowWords.map((word) => word.toLowerCase()));
const words = text.split(/[\s·×]+/).filter(Boolean);
const candidates = [];
for (const word of words) {
// "512", "64", "0.3" and friends carry no letters at all.
if (!/[A-Za-z]/.test(word)) continue;
if (TECH_TOKEN_RE.test(word)) continue;
if (TECH_WORDS.has(word.toLowerCase())) continue;
// Brand and product names are configured per repository, not hardcoded here.
if (allowed.has(word.toLowerCase())) continue;
candidates.push(word);
}
return candidates.length > 0;
}
function reportLiteral(context, node, value, kind) {
context.report({
node,
messageId: "hardcodedText",
data: { value, kind },
});
}
// svelte-eslint-parser exposes an attribute value as a LIST of nodes, so a
// quoted literal is `value[0]`. Anything else ({expr}, spread, no value) means
// the text is computed elsewhere and is out of scope.
function literalValue(attr) {
const parts = Array.isArray(attr.value) ? attr.value : [attr.value];
const first = parts.find((part) => part && typeof part === "object");
if (!first || first.type !== "SvelteLiteral") return null;
return typeof first.value === "string" ? first.value : null;
}
export default {
meta: {
type: "suggestion",
docs: {
description:
"Warn on hardcoded user-visible text in Svelte templates; use t() from the dictionaries.",
category: "i18n",
recommended: false,
},
messages: {
hardcodedText:
"Hardcoded user text ({{kind}}): '{{value}}'. Use t() with a key in both locale dictionaries.",
},
schema: [
{
type: "object",
properties: {
allowWords: {
type: "array",
items: { type: "string" },
},
},
additionalProperties: false,
},
],
},
create(context) {
const { allowWords = [] } = context.options[0] ?? {};
const program = context.sourceCode?.ast ?? context.parserServices?.ast;
function visitTemplate(nodes) {
for (const node of nodes ?? []) {
// <style> and <script> content is code, not copy. Their raw text
// arrives as a text child, so the elements are skipped wholesale.
if (node.type === "SvelteStyleElement") continue;
if (node.type === "SvelteScriptElement") continue;
if (node.type === "SvelteText") {
if (hasUserWords(node.value, allowWords)) {
reportLiteral(context, node, normalize(node.value), "text");
}
continue;
}
if (node.type === "SvelteElement") {
for (const attr of node.startTag?.attributes ?? []) {
const name = attr.key?.name;
if (typeof name !== "string") continue;
if (!USER_TEXT_ATTRS.has(name)) continue;
const value = literalValue(attr);
if (value === null) continue;
if (hasUserWords(value, allowWords)) {
reportLiteral(
context,
attr,
normalize(value),
`${name} attribute`,
);
}
}
}
visitTemplate(node.children);
}
}
return {
"Program:exit"() {
visitTemplate(program?.body);
},
};
},
};
+20 -2
View File
@@ -83,6 +83,7 @@ export default tseslint.config(
"eslint-plugins/__tests__/no-string-union-alias.test.ts", "eslint-plugins/__tests__/no-string-union-alias.test.ts",
"eslint-plugins/__tests__/helpers.ts", "eslint-plugins/__tests__/helpers.ts",
"eslint-plugins/__tests__/dict-consistency.test.ts", "eslint-plugins/__tests__/dict-consistency.test.ts",
"eslint-plugins/__tests__/no-hardcoded-user-text.test.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/dict.ts", "eslint-plugins/__fixtures__/src/lib/i18n/dict.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/en.ts", "eslint-plugins/__fixtures__/src/lib/i18n/en.ts",
"eslint-plugins/__fixtures__/src/lib/i18n/ru.ts", "eslint-plugins/__fixtures__/src/lib/i18n/ru.ts",
@@ -165,8 +166,9 @@ export default tseslint.config(
"conventions/no-string-union-alias": "error", "conventions/no-string-union-alias": "error",
}, },
}, },
// Кросс-языковой линтер словарей (плагин i18n/dict-consistency). // Кросс-языковой линтер словарей (плагин i18n/dict-consistency) и запрет
// Правило сообщает warning, а `lint:all` превращает новые warnings в // захардкоженного пользовательского текста (i18n/no-hardcoded-user-text).
// Правила сообщают warning, а `lint:all` превращает новые warnings в
// blocking через --max-warnings=0. Список локалей берётся из LOCALES в // blocking через --max-warnings=0. Список локалей берётся из LOCALES в
// lib/i18n/dict.ts; новые локали подхватываются автоматически. // lib/i18n/dict.ts; новые локали подхватываются автоматически.
{ {
@@ -184,6 +186,22 @@ export default tseslint.config(
], ],
}, },
}, },
{
// /kit — витрина компонентов с намеренно захардкоженными подписями.
files: newSvelteFiles.filter((pattern) => !pattern.includes("routes")),
plugins: {
i18n: i18nPlugin,
},
rules: {
"i18n/no-hardcoded-user-text": [
"warn",
{
// Название продукта — бренд, а не переводимый текст.
allowWords: ["easy-png-tools"],
},
],
},
},
{ {
files: toolingFiles, files: toolingFiles,
languageOptions: { languageOptions: {
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { t } from "$lib/i18n/t";
import type { import type {
ColorPair, ColorPair,
ColorPairSpec, ColorPairSpec,
@@ -37,7 +38,7 @@
value={current.from} value={current.from}
oninput={(e) => setColor("from", (e.target as HTMLInputElement).value)} oninput={(e) => setColor("from", (e.target as HTMLInputElement).value)}
/> />
<span class="axis-label">From</span> <span class="axis-label">{t("ui.from")}</span>
</label> </label>
<label class="pair-axis"> <label class="pair-axis">
<span class="swatch" style="background:{current.to}"></span> <span class="swatch" style="background:{current.to}"></span>
@@ -46,7 +47,7 @@
value={current.to} value={current.to}
oninput={(e) => setColor("to", (e.target as HTMLInputElement).value)} oninput={(e) => setColor("to", (e.target as HTMLInputElement).value)}
/> />
<span class="axis-label">To</span> <span class="axis-label">{t("ui.to")}</span>
</label> </label>
</div> </div>
</div> </div>