From 0e8c2478a1088713d34765ca08af16d90a21d559 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Thu, 8 Oct 2026 04:51:15 +0500 Subject: [PATCH] chore: add lint rules, fix script\configs --- docs/backlog.md | 13 +- web/eslint-plugins/README.md | 28 ++++ .../__tests__/ascii-only.test.ts | 79 +++++++++++ .../__tests__/comment-format.test.ts | 98 +++++++++++++ .../__tests__/comments-english.test.ts | 96 +++++++++++++ web/eslint-plugins/conventions/ascii-only.js | 61 ++++++++ .../conventions/comment-format.js | 104 ++++++++++++++ .../conventions/comments-english.js | 63 ++++++++ web/eslint-plugins/conventions/index.js | 22 ++- .../conventions/interface-props.js | 30 +--- .../conventions/no-string-union-alias.js | 17 +-- web/eslint-plugins/design-tokens/lists.js | 18 +-- .../design-tokens/no-category-mismatch.js | 20 ++- .../design-tokens/no-hardcoded-in-svelte.js | 30 ++-- .../no-token-definition-in-svelte.js | 14 +- .../design-tokens/no-undefined-in-svelte.js | 2 +- web/eslint-plugins/i18n/dict-consistency.js | 42 ++---- web/eslint-plugins/i18n/index.js | 10 +- .../i18n/no-hardcoded-user-text.js | 20 +-- web/eslint-plugins/index.js | 9 +- web/eslint.config.js | 134 +++++++++++++----- web/playwright.config.ts | 6 +- web/scripts/gen-og.mjs | 6 +- web/scripts/lint-all.mjs | 34 +++-- web/scripts/postcss-hct.mjs | 17 +-- web/scripts/serve-static.mjs | 8 +- web/scripts/token-audit/colors.mjs | 8 +- web/scripts/token-audit/helpers.mjs | 4 +- web/scripts/token-audit/refs.mjs | 7 +- web/scripts/token-audit/unused.mjs | 2 +- web/stylelint.config.js | 21 +-- web/vite.config.ts | 2 +- 32 files changed, 753 insertions(+), 272 deletions(-) create mode 100644 web/eslint-plugins/__tests__/ascii-only.test.ts create mode 100644 web/eslint-plugins/__tests__/comment-format.test.ts create mode 100644 web/eslint-plugins/__tests__/comments-english.test.ts create mode 100644 web/eslint-plugins/conventions/ascii-only.js create mode 100644 web/eslint-plugins/conventions/comment-format.js create mode 100644 web/eslint-plugins/conventions/comments-english.js diff --git a/docs/backlog.md b/docs/backlog.md index 36dde27..37c3f4e 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -208,10 +208,17 @@ `tool-flows.spec.ts`, `schema-tool-strings.test.ts`) - [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint правило такое (запретить toContain без i18n) +- [ ] чистка под новые конвенции комментариев и тестов: правила + `conventions/ascii-only` (тесты — ASCII), `conventions/comments-english` + (комментарии — английский), `conventions/comment-format` (≤5 строк на + блок, ≤15% файла) уже работают в warn-режиме, но в `lint:all` отключены + через override в `web/scripts/lint-all.mjs` (baseline ≠ 0). Критерий + закрытия: ноль срабатываний по всем трём правилам, overrides удалены, + правила переведены с warn на error - [ ] убрать в тестах все нестандартные символы `→`, длинные тире, русский язык - и т.д., сделать lint правило -- [ ] комментарии на английском, lint правило -- [ ] уменьшить количество и многословность комментариев (lint правило?) + и т.д. — часть чистки под `conventions/ascii-only` (см. выше) +- [ ] комментарии на английском — часть чистки под + `conventions/comments-english` (см. выше) ## SEO/GEO diff --git a/web/eslint-plugins/README.md b/web/eslint-plugins/README.md index 1826d6d..6c7077b 100644 --- a/web/eslint-plugins/README.md +++ b/web/eslint-plugins/README.md @@ -131,6 +131,34 @@ report-only (`warn`), baseline — 0 срабатываний. - Тесты: `__tests__/no-hardcoded-user-text.test.ts` — `RuleTester` на svelte-eslint-parser плюс прямые кейсы `hasUserWords`. +## Плагин conventions: текстовые конвенции + +- `conventions/ascii-only` — в файлах, заданных scope'ом конфига (`*.test.ts`, + `*.spec.ts`, `e2e/**`), только ASCII: типографика и неанглийский текст в + `describe`/`it` и ожиданиях мешают поиску и скрывают хардкод локализованных + строк. Осознанные глифы (например `×` в пайплайне) — через опцию `allowChars` + в `eslint.config.js`. Один репорт на строку. +- `conventions/comments-english` — комментарии только на английском, по **белому + списку**: комментарий обязан быть ASCII, поэтому ловятся любые письменности + (кириллица, CJK, арабский), а не один конкретный алфавит; осознанные глифы — + через опцию `allowChars`. Строковые литералы не трогаем — локализованные + значения легальны в словарях. +- `conventions/comment-format` — два мягких потолка: `maxBlockLines` (по + умолчанию 5) строк на один блок комментариев и `maxRatio` (по умолчанию 0.15) + — доля комментарных строк от непустых строк файла; density проверяется только + в файлах от `minLines` непустых строк (в конфиге 100), иначе любая шапка в + маленьком файле даёт ложное превышение. Превышение — сигнал к рефакторингу + кода, а не к сжатию комментария. + +Scope правил комментариев — не только `src/`: сами плагины, `scripts/` и +`*.config.*` живут по тем же конвенциям (dogfooding, их baseline вычищен до +нуля). Фикстуры и тесты правил (`__fixtures__/`, `__tests__/`) — намеренные +входные кейсы и из scope исключены. В `src/` baseline ненулевой, поэтому все три +правила введены warn-only и в `lint:all` отключены через override в +`scripts/lint-all.mjs`, пока не сделана чистка (`docs/backlog.md`, раздел «Тесты +и lint»). Тесты: `__tests__/ascii-only.test.ts`, +`__tests__/comments-english.test.ts`, `__tests__/comment-format.test.ts`. + ## Токены-префиксы (целевой словарь дизайна) - Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две diff --git a/web/eslint-plugins/__tests__/ascii-only.test.ts b/web/eslint-plugins/__tests__/ascii-only.test.ts new file mode 100644 index 0000000..74adc44 --- /dev/null +++ b/web/eslint-plugins/__tests__/ascii-only.test.ts @@ -0,0 +1,79 @@ +// Tests for the conventions/ascii-only rule +// (web/eslint-plugins/conventions/ascii-only.js). +// +// Pure file-content check, no filesystem: standard RuleTester with the +// tseslint parser. The scoping to test files lives in eslint.config.js, so +// filenames here are arbitrary. +import { RuleTester } from "eslint"; +import tseslint from "typescript-eslint"; +import { describe, it } from "vitest"; +import asciiOnly from "../conventions/ascii-only.js"; +import { asRuleModule } from "./helpers.js"; + +RuleTester.describe = describe; +RuleTester.it = it; + +const tester = new RuleTester({ + languageOptions: { + parser: tseslint.parser, + }, +}); + +tester.run("ascii-only", asRuleModule(asciiOnly), { + valid: [ + { code: "const x = 1;", filename: "a.test.ts" }, + { + code: "describe('resize pipeline', () => {});", + filename: "a.test.ts", + }, + { + // Glyphs listed in allowChars pass. + code: "expect(label).toBe('512×512');", + filename: "a.test.ts", + options: [{ allowChars: ["×"] }], + }, + ], + invalid: [ + { + code: "it('ресайзит картинку', () => {});", + filename: "a.test.ts", + errors: [{ messageId: "nonAscii" }], + }, + { + code: "// arrow → here\nconst x = 1;", + filename: "a.test.ts", + errors: [{ messageId: "nonAscii" }], + }, + { + // Em dash and ellipsis on one line collapse to a single report. + code: "it('a — b … c', () => {});", + filename: "a.test.ts", + errors: [{ messageId: "nonAscii" }], + }, + { + // Two offending lines produce two reports. + code: "// über\n// café\nconst x = 1;", + filename: "a.test.ts", + errors: [{ messageId: "nonAscii" }, { messageId: "nonAscii" }], + }, + { + // allowChars does not whitelist other glyphs on the same line. + code: "expect(s).toBe('512×512 → done');", + filename: "a.test.ts", + options: [{ allowChars: ["×"] }], + errors: [{ messageId: "nonAscii" }], + }, + { + // Control characters (here: vertical tab) are not printable ASCII. + code: "const s = '\x0B';", + filename: "a.test.ts", + errors: [{ messageId: "nonAscii" }], + }, + { + // DEL (U+007F) is not printable either. + code: "const s = '\x7F';", + filename: "a.test.ts", + errors: [{ messageId: "nonAscii" }], + }, + ], +}); diff --git a/web/eslint-plugins/__tests__/comment-format.test.ts b/web/eslint-plugins/__tests__/comment-format.test.ts new file mode 100644 index 0000000..66ca4f5 --- /dev/null +++ b/web/eslint-plugins/__tests__/comment-format.test.ts @@ -0,0 +1,98 @@ +// Tests for the conventions/comment-format rule +// (web/eslint-plugins/conventions/comment-format.js). +// +// Pure AST check (comment nodes + line counts), standard RuleTester. +import { RuleTester } from "eslint"; +import tseslint from "typescript-eslint"; +import { describe, it } from "vitest"; +import commentFormat from "../conventions/comment-format.js"; +import { asRuleModule } from "./helpers.js"; + +RuleTester.describe = describe; +RuleTester.it = it; + +const tester = new RuleTester({ + languageOptions: { + parser: tseslint.parser, + }, +}); + +const longBlock = [ + "// one", + "// two", + "// three", + "// four", + "// five", + "// six", + "const x = 1;", +].join("\n"); + +tester.run("comment-format", asRuleModule(commentFormat), { + // Density is off (maxRatio: 1) in the block-focused cases: tiny snippets + // are comment-dominated by construction. + valid: [ + { + code: "// why\nconst x = 1;", + filename: "a.ts", + options: [{ maxRatio: 1 }], + }, + { + code: "// a\n// b\n// c\n// d\n// e\nconst x = 1;", + filename: "a.ts", + options: [{ maxRatio: 1 }], + }, + { + // Blank line breaks the run: two 3-line blocks, both under the cap. + code: "// a\n// b\n// c\n\n// d\n// e\n// f\nconst x = 1;", + filename: "a.ts", + options: [{ maxRatio: 1 }], + }, + { + // Trailing inline comment does not extend the block above it. + code: "// a\n// b\nconst x = 1; // tail", + filename: "a.ts", + options: [{ maxRatio: 1 }], + }, + { + // A wider block cap admits the same comment. + code: longBlock, + filename: "a.ts", + options: [{ maxBlockLines: 6, maxRatio: 1 }], + }, + { + // Density is skipped below minLines: small files are exempt. + code: "// a\n// b\nconst x = 1;\nconst y = 2;", + filename: "a.ts", + options: [{ maxBlockLines: 5, maxRatio: 0.4, minLines: 10 }], + }, + ], + invalid: [ + { + code: longBlock, + filename: "a.ts", + options: [{ maxRatio: 1 }], + errors: [{ messageId: "blockTooLong" }], + }, + { + // A single block comment counts its full line span. + code: "/*\n1\n2\n3\n4\n5\n6\n*/\nconst x = 1;", + filename: "a.ts", + options: [{ maxRatio: 1 }], + errors: [{ messageId: "blockTooLong" }], + }, + { + // Density: 2 comment lines against 2 code lines is 50%. + code: "// a\n// b\nconst x = 1;\nconst y = 2;", + filename: "a.ts", + options: [{ maxBlockLines: 5, maxRatio: 0.4 }], + errors: [{ messageId: "tooDense" }], + }, + { + // Both violations in one file produce two reports. + code: longBlock + "\n// extra", + filename: "a.ts", + options: [{ maxBlockLines: 5, maxRatio: 0.5 }], + errors: [{ messageId: "blockTooLong" }, { messageId: "tooDense" }], + }, + ], +}); diff --git a/web/eslint-plugins/__tests__/comments-english.test.ts b/web/eslint-plugins/__tests__/comments-english.test.ts new file mode 100644 index 0000000..2d1b4a1 --- /dev/null +++ b/web/eslint-plugins/__tests__/comments-english.test.ts @@ -0,0 +1,96 @@ +// Tests for the conventions/comments-english rule +// (web/eslint-plugins/conventions/comments-english.js). +// +// Pure AST check over comment nodes with an ASCII whitelist; string literals +// are ignored by construction (they are not comments), which the cases below +// pin down. +import { RuleTester } from "eslint"; +import tseslint from "typescript-eslint"; +import { describe, it } from "vitest"; +import commentsEnglish from "../conventions/comments-english.js"; +import { asRuleModule } from "./helpers.js"; + +RuleTester.describe = describe; +RuleTester.it = it; + +const tester = new RuleTester({ + languageOptions: { + parser: tseslint.parser, + }, +}); + +tester.run("comments-english", asRuleModule(commentsEnglish), { + valid: [ + { code: "// resize the canvas\nconst x = 1;", filename: "a.ts" }, + { code: "/* multi\nline */\nconst x = 1;", filename: "a.ts" }, + { + // Localized strings are dictionary content, not comments. + code: "const title = 'Пример';", + filename: "a.ts", + }, + { + // Glyphs listed in allowChars pass. + code: "// size 512×512\nconst x = 1;", + filename: "a.ts", + options: [{ allowChars: ["×"] }], + }, + { + // A tab inside a comment is legitimate whitespace. + code: "//\tnote\nconst x = 1;", + filename: "a.ts", + }, + ], + invalid: [ + { + code: "// ресайз холста\nconst x = 1;", + filename: "a.ts", + errors: [{ messageId: "nonAscii" }], + }, + { + // Whitelist catches scripts beyond Cyrillic too. + code: "// 调整画布大小\nconst x = 1;", + filename: "a.ts", + errors: [{ messageId: "nonAscii" }], + }, + { + code: "// تغيير حجم اللوحة\nconst x = 1;", + filename: "a.ts", + errors: [{ messageId: "nonAscii" }], + }, + { + // Typography in an otherwise English comment is still non-ASCII. + code: "// resize — then crop\nconst x = 1;", + filename: "a.ts", + errors: [{ messageId: "nonAscii" }], + }, + { + code: "/* много\nстрочный */\nconst x = 1;", + filename: "a.ts", + errors: [{ messageId: "nonAscii" }], + }, + { + code: "const x = 1; // хвостовой комментарий", + filename: "a.ts", + errors: [{ messageId: "nonAscii" }], + }, + { + // One report per offending comment, not per file. + code: "// раз\n// два\nconst x = 1;", + filename: "a.ts", + errors: [{ messageId: "nonAscii" }, { messageId: "nonAscii" }], + }, + { + // allowChars does not whitelist other glyphs in the same comment. + code: "// 512×512 → done\nconst x = 1;", + filename: "a.ts", + options: [{ allowChars: ["×"] }], + errors: [{ messageId: "nonAscii" }], + }, + { + // Control characters (here: vertical tab) are not printable ASCII. + code: "// \x0B note\nconst x = 1;", + filename: "a.ts", + errors: [{ messageId: "nonAscii" }], + }, + ], +}); diff --git a/web/eslint-plugins/conventions/ascii-only.js b/web/eslint-plugins/conventions/ascii-only.js new file mode 100644 index 0000000..4369d11 --- /dev/null +++ b/web/eslint-plugins/conventions/ascii-only.js @@ -0,0 +1,61 @@ +// Rule: ASCII-ONLY SOURCE (scoped to test files via eslint.config.js). +// Non-ASCII in test names and expectations hurts grep-ability and hides +// hardcoded localized strings; intentional glyphs go to the allowChars +// option. File-content based (not AST), one report per line. +// Details: web/eslint-plugins/README.md. + +const DEFAULT_ALLOW = []; + +export default { + meta: { + type: "suggestion", + docs: { + description: + "Require ASCII-only source in scoped files (tests); exceptions go to the allowChars option.", + category: "Conventions", + recommended: false, + }, + schema: [ + { + type: "object", + properties: { + allowChars: { + type: "array", + items: { type: "string", minLength: 1, maxLength: 1 }, + uniqueItems: true, + }, + }, + additionalProperties: false, + }, + ], + messages: { + nonAscii: + "Non-ASCII character {{chars}} on this line. Tests are written in plain ASCII English; keep intentional glyphs in the rule's allowChars option.", + }, + }, + create(context) { + const allowed = new Set(context.options[0]?.allowChars ?? DEFAULT_ALLOW); + return { + Program() { + const sourceCode = context.sourceCode; + const text = sourceCode.getText(); + const reportedLines = new Set(); + // eslint-disable-next-line no-control-regex -- the printable-ASCII whitelist needs explicit control escapes + for (const match of text.matchAll(/[^\x09\x0A\x0D\x20-\x7E]/g)) { + const char = match[0]; + if (allowed.has(char)) continue; + const loc = sourceCode.getLocFromIndex(match.index); + if (reportedLines.has(loc.line)) continue; + reportedLines.add(loc.line); + context.report({ + loc, + messageId: "nonAscii", + data: { + chars: `'${char}' (U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")})`, + }, + }); + } + }, + }; + }, +}; diff --git a/web/eslint-plugins/conventions/comment-format.js b/web/eslint-plugins/conventions/comment-format.js new file mode 100644 index 0000000..3960782 --- /dev/null +++ b/web/eslint-plugins/conventions/comment-format.js @@ -0,0 +1,104 @@ +// Rule: COMMENT FORMAT: bounded blocks and bounded density. +// maxBlockLines caps one comment block; maxRatio caps the share of comment +// lines in files with minLines+ non-blank lines. Warn-only tripwires: +// excess means refactor the code, not squeeze the comment. +// Details: web/eslint-plugins/README.md. + +function lineSpan(comment) { + return comment.loc.end.line - comment.loc.start.line + 1; +} + +export default { + meta: { + type: "suggestion", + docs: { + description: + "Cap comment block length and overall comment density per file.", + category: "Conventions", + recommended: false, + }, + schema: [ + { + type: "object", + properties: { + maxBlockLines: { type: "integer", minimum: 1 }, + maxRatio: { type: "number", minimum: 0, maximum: 1 }, + minLines: { type: "integer", minimum: 0 }, + }, + additionalProperties: false, + }, + ], + messages: { + blockTooLong: + "Comment block is {{lines}} lines (limit {{max}}). Explain 'why' briefly; move 'what' into names, structure, or a linked doc.", + tooDense: + "Comments take {{percent}}% of this file (limit {{max}}%). Prefer extracting functions/modules over narrating the code.", + }, + }, + create(context) { + const maxBlockLines = context.options[0]?.maxBlockLines ?? 5; + const maxRatio = context.options[0]?.maxRatio ?? 0.15; + const minLines = context.options[0]?.minLines ?? 0; + + return { + Program(node) { + const sourceCode = context.sourceCode; + const comments = sourceCode.getAllComments(); + if (comments.length === 0) return; + + const commentLines = new Set(); + let blockStart = comments[0]; + let blockEndLine = comments[0].loc.end.line; + let blockLines = lineSpan(comments[0]); + + const flushBlock = () => { + if (blockLines > maxBlockLines) { + context.report({ + loc: blockStart.loc, + messageId: "blockTooLong", + data: { lines: String(blockLines), max: String(maxBlockLines) }, + }); + } + }; + + for (let i = 0; i < comments.length; i++) { + const comment = comments[i]; + for (let l = comment.loc.start.line; l <= comment.loc.end.line; l++) { + commentLines.add(l); + } + if (i === 0) continue; + // Adjacent `//` lines form one block; anything else starts a new one. + if ( + comment.type === "Line" && + comments[i - 1].type === "Line" && + comment.loc.start.line === blockEndLine + 1 + ) { + blockEndLine = comment.loc.end.line; + blockLines += 1; + } else { + flushBlock(); + blockStart = comment; + blockEndLine = comment.loc.end.line; + blockLines = lineSpan(comment); + } + } + flushBlock(); + + const totalLines = sourceCode.lines.filter( + (l) => l.trim() !== "", + ).length; + const ratio = commentLines.size / totalLines; + if (totalLines >= minLines && ratio > maxRatio) { + context.report({ + loc: node.loc, + messageId: "tooDense", + data: { + percent: (ratio * 100).toFixed(1), + max: (maxRatio * 100).toFixed(1), + }, + }); + } + }, + }; + }, +}; diff --git a/web/eslint-plugins/conventions/comments-english.js b/web/eslint-plugins/conventions/comments-english.js new file mode 100644 index 0000000..d4777ad --- /dev/null +++ b/web/eslint-plugins/conventions/comments-english.js @@ -0,0 +1,63 @@ +// Rule: COMMENTS IN ENGLISH ONLY. Whitelist, not blacklist: a comment must +// be plain ASCII, so Arabic/CJK/Korean are caught just like Cyrillic. +// Intentional glyphs go to the allowChars option. String literals are NOT +// checked: localized dictionary values legitimately contain other scripts. +// Details: web/eslint-plugins/README.md. + +export default { + meta: { + type: "suggestion", + docs: { + description: + "Require comments to be written in English (ASCII whitelist; exceptions via allowChars).", + category: "Conventions", + recommended: false, + }, + schema: [ + { + type: "object", + properties: { + allowChars: { + type: "array", + items: { type: "string", minLength: 1, maxLength: 1 }, + uniqueItems: true, + }, + }, + additionalProperties: false, + }, + ], + messages: { + nonAscii: + "Comment contains non-ASCII text ({{chars}}). Write comments in English; localized user-facing strings belong in lib/i18n dictionaries.", + }, + }, + create(context) { + const allowed = new Set(context.options[0]?.allowChars ?? []); + return { + Program() { + for (const comment of context.sourceCode.getAllComments()) { + const offenders = new Set(); + for (const match of comment.value.matchAll( + // eslint-disable-next-line no-control-regex -- the printable-ASCII whitelist needs explicit control escapes + /[^\x09\x0A\x0D\x20-\x7E]/g, + )) { + if (!allowed.has(match[0])) offenders.add(match[0]); + } + if (offenders.size === 0) continue; + context.report({ + loc: comment.loc, + messageId: "nonAscii", + data: { + chars: [...offenders] + .map( + (c) => + `'${c}' (U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")})`, + ) + .join(", "), + }, + }); + } + }, + }; + }, +}; diff --git a/web/eslint-plugins/conventions/index.js b/web/eslint-plugins/conventions/index.js index b285211..780d761 100644 --- a/web/eslint-plugins/conventions/index.js +++ b/web/eslint-plugins/conventions/index.js @@ -1,18 +1,11 @@ /** - * 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__. + * Local ESLint plugin "conventions": cross-cutting code conventions the + * recommended rule sets do not enforce. Rule details: + * web/eslint-plugins/README.md. */ +import asciiOnly from "./ascii-only.js"; +import commentFormat from "./comment-format.js"; +import commentsEnglish from "./comments-english.js"; import interfaceProps from "./interface-props.js"; import noStringUnionAlias from "./no-string-union-alias.js"; @@ -22,6 +15,9 @@ export default { version: "0.1.0", }, rules: { + "ascii-only": asciiOnly, + "comment-format": commentFormat, + "comments-english": commentsEnglish, "interface-props": interfaceProps, "no-string-union-alias": noStringUnionAlias, }, diff --git a/web/eslint-plugins/conventions/interface-props.js b/web/eslint-plugins/conventions/interface-props.js index e70d4e8..68c6e1a 100644 --- a/web/eslint-plugins/conventions/interface-props.js +++ b/web/eslint-plugins/conventions/interface-props.js @@ -1,28 +1,8 @@ -// 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