mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
chore: update lint rules
This commit is contained in:
@@ -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
@@ -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,7 +117,10 @@ 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(
|
||||||
|
"no-token-definition-in-svelte",
|
||||||
|
asRuleModule(noTokenDefinition),
|
||||||
|
{
|
||||||
valid: [
|
valid: [
|
||||||
frame(".box { --local: var(--color-fg); }"),
|
frame(".box { --local: var(--color-fg); }"),
|
||||||
frame(".box { --local: calc(var(--space-1) * 2); }"),
|
frame(".box { --local: calc(var(--space-1) * 2); }"),
|
||||||
@@ -137,7 +140,8 @@ describe("design-tokens/no-token-definition-in-svelte", () => {
|
|||||||
errors: [{ messageId: "tokenPrimitive" }],
|
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: {
|
||||||
|
|||||||
@@ -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" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user