From 6a89b08ee571562fea349801169c40f1f993e9b9 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 1 Oct 2026 20:53:23 +0300 Subject: [PATCH] chore: guard presentation data boundaries --- .ai/conventions/typescript-react.md | 8 ++++++ .ai/conventions/ui.md | 6 +++++ eslint.config.mjs | 38 +++++++++++++++++++++++++++ scripts/test-eslint.mjs | 40 +++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+) diff --git a/.ai/conventions/typescript-react.md b/.ai/conventions/typescript-react.md index 3593ce5..8f02b1a 100644 --- a/.ai/conventions/typescript-react.md +++ b/.ai/conventions/typescript-react.md @@ -17,6 +17,10 @@ CSF сохраняет default meta и прямые story exports; configuration exceptions заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code. +Тип значений формы выводится из её schema, когда input/output совпадают. +Общие props определяются у одного owner; DTO и presentation model сохраняют +собственные типы при смысловом отличии. Type re-export сохраняет public imports. + ## Читаемость: ESLint и review ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между @@ -33,6 +37,10 @@ CSS и Markdown также держи в 80 колонках там, где эт Review проверяет то, чего синтаксис не доказывает: +Обоснованное config exception сопровождается причиной и явным ожиданием в +existing lint tests, включая разрешённый и запрещённый примеры. Это позволяет +настраивать профиль без случайного ослабления остальных правил. + - Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат. - Имена derived values описывают значение, например `projectedAmount`. - Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток diff --git a/.ai/conventions/ui.md b/.ai/conventions/ui.md index 879c2f2..7a185e7 100644 --- a/.ai/conventions/ui.md +++ b/.ai/conventions/ui.md @@ -63,6 +63,12 @@ Presentation VM для form-context/UI-only state может использов в каталоге: её граф зависимостей не достигает application data/API. `useTranslation` и декларативный вывод коллекции допустимы во view. +ESLint ограничивает прямые data/auth/router hook imports в `*.view.tsx`; +type imports, error helpers, router components и form context разрешены. +Транзитивный fetching через local presentation hook проверяется review; +Storybook дополнительно проверяет reachable graph. Новое исключение должно +сохранять presentation-only responsibility. + JSX props получают ссылки на функции, включая `onClick`, render props, `dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай `useController` в VM вместо inline `Controller.render`. Значение prop с условием diff --git a/eslint.config.mjs b/eslint.config.mjs index 1222ea7..c467438 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -185,6 +185,44 @@ export default defineConfig( files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"], ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"], }, + { + files: ["src/**/*.view.tsx"], + rules: { + "no-restricted-imports": ["error", { + paths: [ + { + allowTypeImports: true, + message: "Query lifecycle belongs to the data VM.", + name: "@tanstack/react-query", + }, + { + allowTypeImports: true, + message: "Transport belongs to shared/api and the data VM.", + name: "openapi-fetch", + }, + { + allowTypeImports: true, + message: "Transport belongs to shared/api and the data VM.", + name: "openapi-react-query", + }, + ], + patterns: [ + { + allowTypeImports: true, + group: ["react-router", "@/entities/*", "@/features/*"], + importNamePattern: "^use", + message: "Pass route, auth and entity data from the data VM.", + }, + { + allowTypeImports: true, + group: ["@/shared/api", "@/shared/api/*"], + importNamePattern: "^use|^api(?:Query)?Client$", + message: "Fetching belongs to the data VM; error helpers are allowed.", + }, + ], + }], + }, + }, { files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"], ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"], diff --git a/scripts/test-eslint.mjs b/scripts/test-eslint.mjs index 873a884..65fdbe7 100644 --- a/scripts/test-eslint.mjs +++ b/scripts/test-eslint.mjs @@ -338,6 +338,46 @@ test("JSX spacing fixes preserve text interpolation and are stable", async () => assert.equal(again.errorCount, 0); }); +test("views accept presentation inputs but reject direct data hooks", async () => { + const eslint = new ESLint(); + const filePath = "src/features/instrument-details/ui/InstrumentEvents.view.tsx"; + + for (const code of [ + "import { useStock } from \"@/entities/stock\";", + "import { useAuth } from \"@/features/auth\";", + "import { useNavigate } from \"react-router\";", + "import { apiClient } from \"@/shared/api\";", + "import { useQuery } from \"@tanstack/react-query\";", + ]) { + const [result] = await eslint.lintText(code, { filePath }); + assert.equal(result.fatalErrorCount, 0); + assert.ok(result.messages.some(message => + message.ruleId === "no-restricted-imports")); + } + + for (const code of [ + "import { Link } from \"react-router\";", + "import { apiErrorCode } from \"@/shared/api\";", + "import type { StockPosition } from \"@/entities/stock\";", + "import type { QueryKey } from \"@tanstack/react-query\";", + "import { useTranslation } from \"react-i18next\";", + "import { useFormContext } from \"react-hook-form\";", + ]) { + const [result] = await eslint.lintText(code, { filePath }); + assert.equal(result.fatalErrorCount, 0); + assert.ok(result.messages.every(message => + message.ruleId !== "no-restricted-imports")); + } + + const [vm] = await eslint.lintText( + "import { useStock } from \"@/entities/stock\";", + { filePath: "src/pages/stock/ui/StockPage/StockPage.vm.ts" }, + ); + + assert.ok(vm.messages.every(message => + message.ruleId !== "no-restricted-imports")); +}); + test("compact JSX and contract nulls need no local suppressions", async () => { const eslint = new ESLint({ fix: true }); const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";