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`). Здесь — только то,
|
||||
что линтер не умеет.
|
||||
|
||||
Все типизированные пропсы компонентов описываются через локальный
|
||||
`interface Props`, а деструктуризация идёт через аннотацию типа при `$props()`:
|
||||
### Svelte 5
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
Типизация пропсов через локальный `interface Props` закреплена правилом
|
||||
`conventions/interface-props`; руками его нигде дублировать не надо.
|
||||
|
||||
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()`» стандартным
|
||||
> ESLint-правилом не покрывается — остаётся конвенцией.
|
||||
|
||||
### Дизайн: новый визуальный язык
|
||||
|
||||
Описание дизайна — в `docs/plan-redesign.md`. Общие правила:
|
||||
Описание нового визуального языка — в `docs/plan-redesign.md`. Общие правила,
|
||||
которые линтер не проверяет:
|
||||
|
||||
- Новый дизайн живёт в `web/src/app.css` (корневые маршруты), старый — в
|
||||
`web/src/app_v1.css` (маршруты `/v1/*`).
|
||||
- Все повторяющиеся визуальные элементы — отдельные компоненты в
|
||||
`web/src/lib/components/`, даже «просто div с двумя стилями».
|
||||
|
||||
### Линтинг дизайн-токенов
|
||||
|
||||
Запрещено «захардкоживать» дизайн: цвета, размеры, длительности и 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`.
|
||||
- Дизайн-токены (цвета, размеры, длительности, z-index) — только из
|
||||
CSS-переменных; детали ограничений — в `web/eslint-plugins/README.md`.
|
||||
|
||||
### Изоляция веток old ↔ new
|
||||
|
||||
Старый (`v1/`) и новый UI полностью изолированы: ESLint-правило
|
||||
`isolation/no-mixed-imports` резолвит каждый импорт до файла и запрещает
|
||||
смешивание.
|
||||
Старый (`v1/`) и новый UI изолированы: `isolation/no-mixed-imports` резолвит
|
||||
каждый импорт до файла и запрещает смешивание.
|
||||
|
||||
- Trunk-based: коммиты делает разработчик после ревью, самому не коммитить.
|
||||
Изменения делать небольшими (< ~500 строк), атомарными.
|
||||
|
||||
+10
-4
@@ -2,12 +2,17 @@
|
||||
|
||||
## lint и прочие правила
|
||||
|
||||
- [ ] линт правило для `interface Props {...}` + `let {}: Props = $props()`
|
||||
- [ ] линт правило для `object as const`, нежелательно использовать
|
||||
- [x] линт правило для `interface Props {...}` + `let {}: Props = $props()`
|
||||
(плагин `conventions`, правило `interface-props`; автофикс для
|
||||
нетипизированной деструктуризации)
|
||||
- [x] линт правило для `object as const`, нежелательно использовать
|
||||
`type Kind = 'a' | 'b' | '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 с
|
||||
паспортом, семейства с кириллицей)
|
||||
- [ ] Посмотреть темную тему - слишком темная???
|
||||
- [ ] Посмотреть темную тему - слишком темная и слишком много оттенка (убавить c
|
||||
в hct?)
|
||||
- [ ] на картинке результате при работе дергается высота надписи (высота иконки)
|
||||
- [x] Глянуть что за ошибка (воспроизвелось на blur-png, в том числе и повторно)
|
||||
[баг в хроме](https://issues.chromium.org/issues/556160936)
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
// (see __tests__/helpers.ts). The fixture dictionary has exactly five
|
||||
// tokens; anything else must be reported even if it exists in the REAL
|
||||
// web/src/app.css.
|
||||
import { RuleTester, Linter } from "eslint";
|
||||
import { RuleTester } from "eslint";
|
||||
import svelteParser from "svelte-eslint-parser";
|
||||
import tseslint from "typescript-eslint";
|
||||
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 noHardcoded from "../design-tokens/no-hardcoded-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 { verifyInFixtures, type FlatConfig } from "./helpers.js";
|
||||
import { asRuleModule, verifyInFixtures, type FlatConfig } from "./helpers.js";
|
||||
|
||||
const parserOptions = {
|
||||
parser: tseslint.parser,
|
||||
@@ -45,7 +45,7 @@ const frame = (style: string) => ({
|
||||
});
|
||||
|
||||
describe("design-tokens/no-hardcoded-in-svelte", () => {
|
||||
ruleTester.run("no-hardcoded-in-svelte", noHardcoded, {
|
||||
ruleTester.run("no-hardcoded-in-svelte", asRuleModule(noHardcoded), {
|
||||
valid: [
|
||||
frame(".box { color: var(--color-fg); }"),
|
||||
frame(".box { padding: var(--space-3); }"),
|
||||
@@ -97,7 +97,7 @@ describe("design-tokens/no-hardcoded-in-svelte", () => {
|
||||
});
|
||||
|
||||
describe("design-tokens/no-category-mismatch", () => {
|
||||
ruleTester.run("no-category-mismatch", noCategoryMismatch, {
|
||||
ruleTester.run("no-category-mismatch", asRuleModule(noCategoryMismatch), {
|
||||
valid: [
|
||||
frame(".box { padding: var(--space-1); }"),
|
||||
frame(".box { color: var(--color-fg); }"),
|
||||
@@ -117,7 +117,10 @@ describe("design-tokens/no-category-mismatch", () => {
|
||||
});
|
||||
|
||||
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: [
|
||||
frame(".box { --local: var(--color-fg); }"),
|
||||
frame(".box { --local: calc(var(--space-1) * 2); }"),
|
||||
@@ -137,7 +140,8 @@ describe("design-tokens/no-token-definition-in-svelte", () => {
|
||||
errors: [{ messageId: "tokenPrimitive" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("design-tokens/no-undefined-in-svelte", () => {
|
||||
@@ -145,7 +149,9 @@ describe("design-tokens/no-undefined-in-svelte", () => {
|
||||
{
|
||||
files: ["**/*.svelte"],
|
||||
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" },
|
||||
languageOptions: {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// `verifyInFixtures` runs a rule with cwd fixed to `__fixtures__/`, so both
|
||||
// work without touching process.cwd() or the real src/ tree.
|
||||
import { Linter } from "eslint";
|
||||
import type { Rule } from "eslint";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -32,3 +33,18 @@ export function verifyInFixtures(
|
||||
const linter = new Linter({ configType: "flat", cwd: FIXTURES_DIR });
|
||||
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, ...
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 = [
|
||||
{
|
||||
files: ["**/*.{ts,svelte}"],
|
||||
plugins: { isolation: { rules: { "no-mixed-imports": noMixedImports } } },
|
||||
plugins: {
|
||||
isolation: {
|
||||
rules: { "no-mixed-imports": asRuleModule(noMixedImports) },
|
||||
},
|
||||
},
|
||||
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 designTokens from "./eslint-plugins/index.js";
|
||||
import isolationPlugin from "./eslint-plugins/isolation/index.js";
|
||||
import conventionsPlugin from "./eslint-plugins/conventions/index.js";
|
||||
|
||||
// FIXME: надо игнорировать старые файлы, после переноса пути новых компонентов включают старые
|
||||
// Новый код редизайна: к нему применяем полные recommended-наборы уже сейчас.
|
||||
@@ -76,6 +77,8 @@ export default tseslint.config(
|
||||
// через default-проект. Перечисляются точечно: `**` в
|
||||
// allowDefaultProject запрещён tseslint.
|
||||
"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__/no-mixed-imports.test.ts",
|
||||
"eslint-plugins/__fixtures__/src/lib/v1/old.ts",
|
||||
@@ -138,6 +141,31 @@ export default tseslint.config(
|
||||
"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-наборы — только на новый код.
|
||||
...[
|
||||
...jsRecommended.map((cfg) => ({
|
||||
|
||||
Reference in New Issue
Block a user