chore: update lint rules

This commit is contained in:
2026-09-11 07:39:51 +05:00
parent 87bd4482a3
commit fa5f7660c7
12 changed files with 532 additions and 79 deletions
+20 -46
View File
@@ -24,66 +24,40 @@
## Правила кода ## Правила кода
### Svelte 5: типизация props через `interface Props` Синтаксические ограничения описывать не нужно — они проверяются линтером
(весь список правил и токены-префиксы в `web/eslint-plugins/README.md`,
прогон: `pnpm --dir web lint` / `pnpm --dir web lint:all`). Здесь — только то,
что линтер не умеет.
Все типизированные пропсы компонентов описываются через локальный ### Svelte 5
`interface Props`, а деструктуризация идёт через аннотацию типа при `$props()`:
```svelte Типизация пропсов через локальный `interface Props` закреплена правилом
<script lang="ts"> `conventions/interface-props`; руками его нигде дублировать не надо.
import type { Snippet } from 'svelte';
interface Props { ### Сначала искать
label: string;
accent?: boolean;
children?: Snippet;
}
let { label, accent = false, children }: Props = $props(); Новая константа или тип не заводится, пока не произведён поиск существующего
</script> определения (по ключу в соседних модулях и по всему `web/src/`). Это правило
``` в первую очередь для ИИ-агентов: дублирующее определение ухудшает правки в
нескольких местах и запутывает. (Синтаксис самих определений — под линтером:
строка `conventions/no-string-union-alias` и т.д.)
Не использовать инлайн-дженерик `$props<{ ... }>()` — он тяжело читается и ### Дизайн
разносит тип и деструктуризацию по разным местам. Также **не использовать
инлайн-импорты в типах** (`children?: import('svelte').Snippet;`) — все
`import type` поднимаются наверх файла.
> Правило «всегда `interface Props` + `let {...}: Props = $props()`» стандартным Описание нового визуального языка — в `docs/plan-redesign.md`. Общие правила,
> ESLint-правилом не покрывается — остаётся конвенцией. которые линтер не проверяет:
### Дизайн: новый визуальный язык
Описание дизайна — в `docs/plan-redesign.md`. Общие правила:
- Новый дизайн живёт в `web/src/app.css` (корневые маршруты), старый — в - Новый дизайн живёт в `web/src/app.css` (корневые маршруты), старый — в
`web/src/app_v1.css` (маршруты `/v1/*`). `web/src/app_v1.css` (маршруты `/v1/*`).
- Все повторяющиеся визуальные элементы — отдельные компоненты в - Все повторяющиеся визуальные элементы — отдельные компоненты в
`web/src/lib/components/`, даже «просто div с двумя стилями». `web/src/lib/components/`, даже «просто div с двумя стилями».
- Дизайн-токены (цвета, размеры, длительности, z-index) — только из
### Линтинг дизайн-токенов CSS-переменных; детали ограничений — в `web/eslint-plugins/README.md`.
Запрещено «захардкоживать» дизайн: цвета, размеры, длительности и z-index
обязаны приходить из CSS-переменных. Прогон: `pnpm --dir web lint:all`.
- **Цвета**: только `hct(...)` в `app.css`, seed-токены
(`--brand-main`/`--brand-alt`) и `color-mix(...)` — исключения.
`oklch()/rgb()/#hex` в `--color-*` запрещены.
- **Размеры**: `--space-*`, `--text-*`, `--radius-*`, `--size-*`.
- **Breakpoints**: `@custom-media --bp-*` (объявления в `app.css`, используются
как `@media (--bp-*)`).
- **z-index**: `--z-*`; **длительности**: `--duration-*`, `--ease-*`.
- В `<style>` svelte-компонентов: нельзя хардкодить цвета/размеры/длительности,
нельзя использовать необъявленные `var(--x)`, нельзя путать категории
(color-токен в size-свойстве).
Детали: полный список правил плагина, токены-префиксы, настройка stylelint — см.
`web/eslint-plugins/README.md`.
### Изоляция веток old ↔ new ### Изоляция веток old ↔ new
Старый (`v1/`) и новый UI полностью изолированы: ESLint-правило Старый (`v1/`) и новый UI изолированы: `isolation/no-mixed-imports` резолвит
`isolation/no-mixed-imports` резолвит каждый импорт до файла и запрещает каждый импорт до файла и запрещает смешивание.
смешивание.
- Trunk-based: коммиты делает разработчик после ревью, самому не коммитить. - Trunk-based: коммиты делает разработчик после ревью, самому не коммитить.
Изменения делать небольшими (< ~500 строк), атомарными. Изменения делать небольшими (< ~500 строк), атомарными.
+10 -4
View File
@@ -2,12 +2,17 @@
## lint и прочие правила ## lint и прочие правила
- [ ] линт правило для `interface Props {...}` + `let {}: Props = $props()` - [x] линт правило для `interface Props {...}` + `let {}: Props = $props()`
- [ ] линт правило для `object as const`, нежелательно использовать (плагин `conventions`, правило `interface-props`; автофикс для
нетипизированной деструктуризации)
- [x] линт правило для `object as const`, нежелательно использовать
`type Kind = 'a' | 'b' | 'c'` - это приводит к дублированию описаний в `type Kind = 'a' | 'b' | 'c'` - это приводит к дублированию описаний в
разных местах, и к непонятным новым типам типа `type Kind2 = 'a' | 'c'` разных местах, и к непонятным новым типам типа `type Kind2 = 'a' | 'c'`
- [ ] можно ли сделать правило для минимизации новых `const` определений? чтобы (правило `conventions/no-string-union-alias`)
- [x] можно ли сделать правило для минимизации новых `const` определений? чтобы
дубли автоматически определялись? ИИ агент иногда дублирует определения дубли автоматически определялись? ИИ агент иногда дублирует определения
(как линт-правило — нереализуемо без семантики; решено ограничением в
AGENTS.md «сначала искать», авто-детект дублей в файле — открытый вопрос)
## Мелочи всякие ## Мелочи всякие
@@ -24,7 +29,8 @@
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
паспортом, семейства с кириллицей) паспортом, семейства с кириллицей)
- [ ] Посмотреть темную тему - слишком темная??? - [ ] Посмотреть темную тему - слишком темная и слишком много оттенка (убавить c
в hct?)
- [ ] на картинке результате при работе дергается высота надписи (высота иконки) - [ ] на картинке результате при работе дергается высота надписи (высота иконки)
- [x] Глянуть что за ошибка (воспроизвелось на blur-png, в том числе и повторно) - [x] Глянуть что за ошибка (воспроизвелось на blur-png, в том числе и повторно)
[баг в хроме](https://issues.chromium.org/issues/556160936) [баг в хроме](https://issues.chromium.org/issues/556160936)
@@ -8,15 +8,15 @@
// (see __tests__/helpers.ts). The fixture dictionary has exactly five // (see __tests__/helpers.ts). The fixture dictionary has exactly five
// tokens; anything else must be reported even if it exists in the REAL // tokens; anything else must be reported even if it exists in the REAL
// web/src/app.css. // web/src/app.css.
import { RuleTester, Linter } from "eslint"; import { RuleTester } from "eslint";
import svelteParser from "svelte-eslint-parser"; import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import noHardcoded from "../design-tokens/no-hardcoded-in-svelte.js";
import noCategoryMismatch from "../design-tokens/no-category-mismatch.js"; import noCategoryMismatch from "../design-tokens/no-category-mismatch.js";
import noHardcoded from "../design-tokens/no-hardcoded-in-svelte.js";
import noTokenDefinition from "../design-tokens/no-token-definition-in-svelte.js"; import noTokenDefinition from "../design-tokens/no-token-definition-in-svelte.js";
import noUndefined from "../design-tokens/no-undefined-in-svelte.js"; import noUndefined from "../design-tokens/no-undefined-in-svelte.js";
import { verifyInFixtures, type FlatConfig } from "./helpers.js"; import { asRuleModule, verifyInFixtures, type FlatConfig } from "./helpers.js";
const parserOptions = { const parserOptions = {
parser: tseslint.parser, parser: tseslint.parser,
@@ -45,7 +45,7 @@ const frame = (style: string) => ({
}); });
describe("design-tokens/no-hardcoded-in-svelte", () => { describe("design-tokens/no-hardcoded-in-svelte", () => {
ruleTester.run("no-hardcoded-in-svelte", noHardcoded, { ruleTester.run("no-hardcoded-in-svelte", asRuleModule(noHardcoded), {
valid: [ valid: [
frame(".box { color: var(--color-fg); }"), frame(".box { color: var(--color-fg); }"),
frame(".box { padding: var(--space-3); }"), frame(".box { padding: var(--space-3); }"),
@@ -97,7 +97,7 @@ describe("design-tokens/no-hardcoded-in-svelte", () => {
}); });
describe("design-tokens/no-category-mismatch", () => { describe("design-tokens/no-category-mismatch", () => {
ruleTester.run("no-category-mismatch", noCategoryMismatch, { ruleTester.run("no-category-mismatch", asRuleModule(noCategoryMismatch), {
valid: [ valid: [
frame(".box { padding: var(--space-1); }"), frame(".box { padding: var(--space-1); }"),
frame(".box { color: var(--color-fg); }"), frame(".box { color: var(--color-fg); }"),
@@ -117,27 +117,31 @@ describe("design-tokens/no-category-mismatch", () => {
}); });
describe("design-tokens/no-token-definition-in-svelte", () => { describe("design-tokens/no-token-definition-in-svelte", () => {
ruleTester.run("no-token-definition-in-svelte", noTokenDefinition, { ruleTester.run(
valid: [ "no-token-definition-in-svelte",
frame(".box { --local: var(--color-fg); }"), asRuleModule(noTokenDefinition),
frame(".box { --local: calc(var(--space-1) * 2); }"), {
frame(".box { --ratio: 1.5; }"), valid: [
], frame(".box { --local: var(--color-fg); }"),
invalid: [ frame(".box { --local: calc(var(--space-1) * 2); }"),
{ frame(".box { --ratio: 1.5; }"),
...frame(".box { --local: #ff0000; }"), ],
errors: [{ messageId: "tokenPrimitive" }], invalid: [
}, {
{ ...frame(".box { --local: #ff0000; }"),
...frame(".box { --local: 12px; }"), errors: [{ messageId: "tokenPrimitive" }],
errors: [{ messageId: "tokenPrimitive" }], },
}, {
{ ...frame(".box { --local: 12px; }"),
...frame(".box { --local: oklch(0.5 0.1 240); }"), errors: [{ messageId: "tokenPrimitive" }],
errors: [{ messageId: "tokenPrimitive" }], },
}, {
], ...frame(".box { --local: oklch(0.5 0.1 240); }"),
}); errors: [{ messageId: "tokenPrimitive" }],
},
],
},
);
}); });
describe("design-tokens/no-undefined-in-svelte", () => { describe("design-tokens/no-undefined-in-svelte", () => {
@@ -145,7 +149,9 @@ describe("design-tokens/no-undefined-in-svelte", () => {
{ {
files: ["**/*.svelte"], files: ["**/*.svelte"],
plugins: { plugins: {
"design-tokens": { rules: { "no-undefined-in-svelte": noUndefined } }, "design-tokens": {
rules: { "no-undefined-in-svelte": asRuleModule(noUndefined) },
},
}, },
rules: { "design-tokens/no-undefined-in-svelte": "error" }, rules: { "design-tokens/no-undefined-in-svelte": "error" },
languageOptions: { languageOptions: {
+16
View File
@@ -9,6 +9,7 @@
// `verifyInFixtures` runs a rule with cwd fixed to `__fixtures__/`, so both // `verifyInFixtures` runs a rule with cwd fixed to `__fixtures__/`, so both
// work without touching process.cwd() or the real src/ tree. // work without touching process.cwd() or the real src/ tree.
import { Linter } from "eslint"; import { Linter } from "eslint";
import type { Rule } from "eslint";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -32,3 +33,18 @@ export function verifyInFixtures(
const linter = new Linter({ configType: "flat", cwd: FIXTURES_DIR }); const linter = new Linter({ configType: "flat", cwd: FIXTURES_DIR });
return linter.verify(code, config, path.join(FIXTURES_SRC, relFile)); return linter.verify(code, config, path.join(FIXTURES_SRC, relFile));
} }
/** Wrap a script body into a minimal Svelte component (runs in runes mode). */
export function svelteComponent(script: string): string {
return `<main>Hello</main>\n\n<script lang="ts">\n${script}\n</script>\n`;
}
/**
* Narrow an inferred JS rule to the typed `Rule.RuleModule`. The plugin rule
* files are plain JS, so TypeScript infers a widened shape (`meta.type: string`)
* that `RuleTester.run` rejects; the shape is structurally correct at runtime,
* which is why existing rules keep their JS inference and only the tests cast.
*/
export function asRuleModule(rule: unknown): Rule.RuleModule {
return rule as Rule.RuleModule;
}
@@ -0,0 +1,99 @@
// Tests for the conventions/interface-props rule
// (web/eslint-plugins/conventions/interface-props.js).
//
// The rule is a pure AST check (no filesystem), so it runs through the standard
// RuleTester with svelte-eslint-parser + the TS sub-parser. No fixtures needed.
import { RuleTester } from "eslint";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import { describe, it } from "vitest";
import interfaceProps from "../conventions/interface-props.js";
import { asRuleModule, svelteComponent } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const ruleTester = new RuleTester({
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tseslint.parser },
},
});
const frame = (script: string) => ({
code: svelteComponent(script),
filename: "Component.svelte",
});
ruleTester.run("interface-props", asRuleModule(interfaceProps), {
valid: [
{ code: "<main>Hello</main>", filename: "Component.svelte" },
frame("let x = 1;"),
// $state / $derived calls are unrelated, not props.
frame("const s = $state(0);"),
frame("const doubled = $derived(s * 2);"),
frame(
"interface Props { label: string }\nlet { label }: Props = $props();",
),
frame(
"interface Props { label: string }\nlet { label = 'x' }: Props = $props();",
),
frame(
"interface Props { value: string }\nlet { value = $bindable() }: Props = $props();",
),
// Interface declared after the destructuring is still fine.
frame(
"let { label }: Props = $props();\ninterface Props { label: string }",
),
frame(
"interface Props { id: string; rest?: unknown }\nlet { id, ...rest }: Props = $props();",
),
frame(
"import type { Snippet } from 'svelte';\n" +
"interface Props { children?: Snippet }\n" +
"let { children }: Props = $props();",
),
],
invalid: [
{
// Untyped destructuring with a local interface -> autofixable.
...frame("interface Props { label: string }\nlet { label } = $props();"),
output: svelteComponent(
"interface Props { label: string }\nlet { label }: Props = $props();",
),
errors: [{ messageId: "untypedDestructure" }],
},
{
// Untyped destructuring without an interface -> report, no fix.
...frame("let { label } = $props();"),
errors: [{ messageId: "untypedDestructure" }],
},
{
// Binding the whole props object instead of destructuring.
...frame("let props = $props();"),
errors: [{ messageId: "noDestructure" }],
},
{
// Inline generic is banned outright.
...frame("const props = $props<{ a: string }>();"),
errors: [{ messageId: "inlineGeneric" }],
},
{
// A different type name than the local interface Props.
...frame(
"interface Props { a: string }\nlet { a }: StageProps = $props();",
),
errors: [{ messageId: "notNamedProps" }],
},
{
// Inline object props type instead of interface Props.
...frame("let { a }: { a: string } = $props();"),
errors: [{ messageId: "inlineObjectType" }],
},
{
// Reference to Props without any local declaration.
...frame("let { a }: Props = $props();"),
errors: [{ messageId: "missingInterface" }],
},
],
});
@@ -11,12 +11,16 @@
// shared-> everything else: core/, i18n/, theme, ... // shared-> everything else: core/, i18n/, theme, ...
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import noMixedImports from "../isolation/no-mixed-imports.js"; import noMixedImports from "../isolation/no-mixed-imports.js";
import { verifyInFixtures, type FlatConfig } from "./helpers.js"; import { asRuleModule, verifyInFixtures, type FlatConfig } from "./helpers.js";
const isolationConfig: FlatConfig = [ const isolationConfig: FlatConfig = [
{ {
files: ["**/*.{ts,svelte}"], files: ["**/*.{ts,svelte}"],
plugins: { isolation: { rules: { "no-mixed-imports": noMixedImports } } }, plugins: {
isolation: {
rules: { "no-mixed-imports": asRuleModule(noMixedImports) },
},
},
rules: { "isolation/no-mixed-imports": "error" }, rules: { "isolation/no-mixed-imports": "error" },
}, },
]; ];
@@ -0,0 +1,78 @@
// Tests for the conventions/no-string-union-alias rule
// (web/eslint-plugins/conventions/no-string-union-alias.js).
//
// The rule is a pure AST check (no filesystem), so it runs through the standard
// RuleTester: plain .ts via the tseslint parser, and (because the rule is also
// enabled on new .svelte scripts) .svelte via the svelte tester. No fixtures.
import { RuleTester } from "eslint";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import { describe, it } from "vitest";
import noStringUnionAlias from "../conventions/no-string-union-alias.js";
import { asRuleModule, svelteComponent } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const tsTester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
},
});
const svelteTester = new RuleTester({
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tseslint.parser },
},
});
const frame = (script: string) => ({
code: svelteComponent(script),
filename: "Component.svelte",
});
tsTester.run("no-string-union-alias", asRuleModule(noStringUnionAlias), {
valid: [
{ code: "type X = 1 | 2;", filename: "types.ts" },
{ code: "type X = 'a' | number;", filename: "types.ts" },
{ code: "type X = string;", filename: "types.ts" },
{ code: "const KIND = { a: 1, b: 2 } as const;", filename: "types.ts" },
{ code: "function f(x: 'a' | 'b') {}", filename: "types.ts" },
{ code: "const x: 'a' | 'b' = 'a';", filename: "types.ts" },
{ code: "type X = 'a' | TemplateStrings;", filename: "types.ts" },
],
invalid: [
{
code: "type Kind = 'a' | 'b' | 'c';",
filename: "types.ts",
errors: [{ messageId: "stringUnion" }],
},
{
// Nested/parenthesized unions still resolve to string literals.
code: "type Kind = ('a' | 'b') | 'c';",
filename: "types.ts",
errors: [{ messageId: "stringUnion" }],
},
{
code: "type Lang =\n\t'en'\n\t| 'ru';",
filename: "types.ts",
errors: [{ messageId: "stringUnion" }],
},
],
});
// The rule also runs on new .svelte files (their scripts are TS).
svelteTester.run(
"no-string-union-alias (svelte script)",
asRuleModule(noStringUnionAlias),
{
valid: [frame("type X = 1 | 2;"), frame("const M = { a: 1 } as const;")],
invalid: [
{
...frame("type Kind = 'a' | 'b' | 'c';"),
errors: [{ messageId: "stringUnion" }],
},
],
},
);
+28
View File
@@ -0,0 +1,28 @@
/**
* Local ESLint plugin "conventions".
*
* Cross-cutting code conventions that the recommended rule sets don't enforce
* (and plain @typescript-eslint rules cannot express in one shot):
*
* - interface-props: Svelte 5 props always go through a local `interface
* Props` + `let { ... }: Props = $props()` (no inline generics, no inline
* type imports, no untyped destructuring);
* - no-string-union-alias: string-literal union aliases (`type Kind = 'a' |
* 'b'`) are banned in favor of a single `as const` object + indexed access,
* so literal sets live in exactly one place.
*
* Rules are AST-only (no filesystem), see the tests in eslint-plugins/__tests__.
*/
import interfaceProps from "./interface-props.js";
import noStringUnionAlias from "./no-string-union-alias.js";
export default {
meta: {
name: "conventions",
version: "0.1.0",
},
rules: {
"interface-props": interfaceProps,
"no-string-union-alias": noStringUnionAlias,
},
};
@@ -0,0 +1,135 @@
// Rule: SVELTE 5 PROPS MUST USE A LOCAL `interface Props`.
//
// Convention (AGENTS.md): every typed props of a component is described by a
// local `interface Props`, and the props are destructured with the annotation:
//
// interface Props {
// label: string;
// accent?: boolean;
// children?: Snippet;
// }
// let { label, accent = false, children }: Props = $props();
//
// Banned instead:
// - the inline generic `$props<{ ... }>()` — hard to read and splits the
// type away from the file structure;
// - untyped destructuring `let { ... } = $props()`;
// - binding the whole props object (`const props = $props()`);
// - any props type name other than the local `interface Props`.
//
// Inline `import('...')` type queries are NOT checked here: they are already
// banned by @typescript-eslint/consistent-type-imports.
//
// The check is purely syntactic (AST-level). Svelte's runes model guarantees
// $props() only exists in instance <script> blocks, so no scope/type info is
// needed. Interfaces are collected from every <script> (module + instance).
import { isInsideScriptElement } from "./utils.js";
export default {
meta: {
type: "suggestion",
fixable: "code",
docs: {
description:
"Require a local `interface Props` and `let { ... }: Props = $props()` for Svelte 5 props; ban inline generics, untyped destructuring and non-local props type names.",
category: "Svelte conventions",
recommended: true,
},
schema: [],
messages: {
inlineGeneric:
"Avoid the inline generic `$props<T>()`; declare a local `interface Props` and destructure it: `let { ... }: Props = $props()`.",
untypedDestructure:
"Annotate the props destructuring with a local `interface Props`: `let { ... }: Props = $props()`.",
noDestructure:
"Destructure props with `let { ... }: Props = $props()` instead of binding the whole `$props()` object.",
notNamedProps:
"The props type must be the local `interface Props` (found '{{name}}').",
inlineObjectType:
"Declare a local `interface Props` instead of an inline object props type.",
missingInterface:
"No local `interface Props` is declared in this component; add one and use it as the props type.",
},
},
create(context) {
const interfaces = new Set();
return {
Program(node) {
for (const child of node.body) {
if (child.type !== "SvelteScriptElement") continue;
for (const stmt of child.body ?? []) {
if (stmt.type !== "TSInterfaceDeclaration") continue;
interfaces.add(stmt.id.name);
}
}
},
CallExpression(node) {
if (
node.callee.type !== "Identifier" ||
node.callee.name !== "$props"
) {
return;
}
if (!isInsideScriptElement(node)) return;
// Inline generic $props<{ ... }>() is banned outright.
if (node.typeArguments?.params?.length) {
context.report({ node: node.callee, messageId: "inlineGeneric" });
return;
}
let declarator = node.parent;
while (declarator && declarator.type !== "VariableDeclarator") {
declarator = declarator.parent;
}
if (!declarator) return;
const id = declarator.id;
if (id.type === "ObjectPattern") {
const annotation = id.typeAnnotation?.typeAnnotation ?? null;
if (annotation) {
if (annotation.type === "TSTypeReference") {
const typeName = annotation.typeName;
if (!typeName || typeName.type !== "Identifier") return;
if (typeName.name !== "Props") {
context.report({
node: typeName,
messageId: "notNamedProps",
data: { name: typeName.name },
});
} else if (!interfaces.has("Props")) {
context.report({
node: typeName,
messageId: "missingInterface",
});
}
} else if (annotation.type !== "TSImportType") {
context.report({
node: annotation,
messageId: "inlineObjectType",
});
}
} else {
context.report({
node: id,
messageId: "untypedDestructure",
...(interfaces.has("Props")
? {
fix: (fixer) => fixer.insertTextAfter(id, ": Props"),
}
: {}),
});
}
return;
}
if (id.type === "Identifier") {
context.report({ node: id, messageId: "noDestructure" });
}
},
};
},
};
@@ -0,0 +1,64 @@
// Rule: NO STRING-LITERAL UNION TYPE ALIASES.
//
// A type alias whose members are all string literals (`type Kind = 'a' | 'b'`)
// duplicates the literal set: the same set is re-typed in many places, and new
// "derived" aliases (`type Kind2 = 'a' | 'c'`) appear that drift from the
// source. Prefer a single `as const` object as the source of truth and derive
// the type from it with indexed access.
//
// const KIND = { a: ..., b: ..., c: ... } as const;
// type Kind = (typeof KIND)[keyof typeof KIND];
//
// Only TSTypeAliasDeclaration is checked — inline unions in parameter or
// property types (one-off uses) are left alone.
/** True when the union and all its (possibly nested) members are string literals. */
function allStringLiterals(/** @type {any} */ union) {
for (const member of union.types) {
if (member.type === "TSUnionType") {
if (!allStringLiterals(member)) return false;
} else if (member.type === "TSLiteralType") {
const literal = member.literal;
if (!(literal.type === "Literal" && typeof literal.value === "string")) {
return false;
}
} else {
return false;
}
}
return true;
}
export default {
meta: {
type: "suggestion",
docs: {
description:
"Ban string-literal union type aliases in favor of an `as const` object plus `(typeof X)[keyof typeof X]`.",
category: "TypeScript conventions",
recommended: true,
},
schema: [],
messages: {
stringUnion:
"Prefer one `as const` object over the string-literal union alias '{{name}}' — literal sets duplicated across types or files drift apart. Derive the type instead: `const {{constName}} = {...} as const; type {{name}} = (typeof {{constName}})[keyof typeof {{constName}}]`.",
},
},
create(context) {
return {
TSTypeAliasDeclaration(node) {
const annotation = node.typeAnnotation;
if (!annotation || annotation.type !== "TSUnionType") return;
if (!allStringLiterals(annotation)) return;
context.report({
node: node.id,
messageId: "stringUnion",
data: {
name: node.id.name,
constName: node.id.name.toUpperCase(),
},
});
},
};
},
};
+15
View File
@@ -0,0 +1,15 @@
// Shared helpers for the conventions plugin (web/eslint-plugins/conventions).
/**
* True when the node sits somewhere under a Svelte <script> block
* (SvelteScriptElement). Used to limit $props() checks to script code.
* @param {any} node
*/
export function isInsideScriptElement(node) {
let cursor = node.parent;
while (cursor && cursor.type !== "Program") {
if (cursor.type === "SvelteScriptElement") return true;
cursor = cursor.parent;
}
return false;
}
+28
View File
@@ -6,6 +6,7 @@ import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
import designTokens from "./eslint-plugins/index.js"; import designTokens from "./eslint-plugins/index.js";
import isolationPlugin from "./eslint-plugins/isolation/index.js"; import isolationPlugin from "./eslint-plugins/isolation/index.js";
import conventionsPlugin from "./eslint-plugins/conventions/index.js";
// FIXME: надо игнорировать старые файлы, после переноса пути новых компонентов включают старые // FIXME: надо игнорировать старые файлы, после переноса пути новых компонентов включают старые
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас. // Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
@@ -76,6 +77,8 @@ export default tseslint.config(
// через default-проект. Перечисляются точечно: `**` в // через default-проект. Перечисляются точечно: `**` в
// allowDefaultProject запрещён tseslint. // allowDefaultProject запрещён tseslint.
"eslint-plugins/__tests__/design-tokens.test.ts", "eslint-plugins/__tests__/design-tokens.test.ts",
"eslint-plugins/__tests__/interface-props.test.ts",
"eslint-plugins/__tests__/no-string-union-alias.test.ts",
"eslint-plugins/__tests__/helpers.ts", "eslint-plugins/__tests__/helpers.ts",
"eslint-plugins/__tests__/no-mixed-imports.test.ts", "eslint-plugins/__tests__/no-mixed-imports.test.ts",
"eslint-plugins/__fixtures__/src/lib/v1/old.ts", "eslint-plugins/__fixtures__/src/lib/v1/old.ts",
@@ -138,6 +141,31 @@ export default tseslint.config(
"design-tokens/no-undefined-in-svelte": "error", "design-tokens/no-undefined-in-svelte": "error",
}, },
}, },
// Конвенция Svelte 5: пропсы через локальный `interface Props` +
// `let {...}: Props = $props()` (плагин conventions/interface-props).
// Только Svelte-файлы нового кода (см. newSvelteFiles).
{
files: newSvelteFiles,
ignores: oldSvelteFiles,
plugins: {
conventions: conventionsPlugin,
},
rules: {
"conventions/interface-props": "error",
},
},
// Запрет строковых union-алиасов в пользу `as const` объектов
// (плагин conventions/no-string-union-alias). TS и Svelte-скрипты нового кода.
{
files: newCode,
ignores: oldCode,
plugins: {
conventions: conventionsPlugin,
},
rules: {
"conventions/no-string-union-alias": "error",
},
},
// Полные recommended-наборы — только на новый код. // Полные recommended-наборы — только на новый код.
...[ ...[
...jsRecommended.map((cfg) => ({ ...jsRecommended.map((cfg) => ({