diff --git a/.husky/pre-commit b/.husky/pre-commit index 3b1bd1c..59833fa 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,2 @@ npx lint-staged -npm run openapi:check npm run deps diff --git a/AGENTS.md b/AGENTS.md index 7d7c579..23021ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ npm ci npm run verify ``` -Gate non-mutating и проверяет OpenAPI snapshot, strict TypeScript, Biome, risk-based tests/coverage ratchet, Knip, dependency boundaries, build и bundle budget. `npm run openapi:sync` — отдельная записывающая команда для атомарного обновления snapshot/types; обе стороны API contract сверяются отдельной backend командой `npm run test:openapi-contract`. +Gate non-mutating и проверяет strict TypeScript, Biome, risk-based tests/coverage ratchet, Knip, dependency boundaries и build. Если backend API contract меняется, committed frontend snapshot `src/shared/api/spec/openapi.json` обновляется явным копированием backend OpenAPI document, затем `npm run openapi:generate` регенерирует committed types; обе стороны API contract сверяются отдельной backend командой `npm run test:openapi-contract`. Актуальные scripts проверять в `package.json`/`npm run`. ## Enforceable boundaries diff --git a/README.md b/README.md index 6dd7741..67bfd69 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,11 @@ npm run dev npm run verify ``` -Команда сначала отклоняет runtime вне Node 22, затем проверяет committed -OpenAPI snapshot, repository hygiene, strict TypeScript, Biome, один -coverage-backed запуск Vitest, Knip, dependency rules, production build и -bundle budget. Для обновления OpenAPI artifacts применяется только -`npm run openapi:sync`; обычный `openapi:check` обращается лишь к committed -snapshot. +Команда сначала отклоняет runtime вне Node 22, затем проверяет repository +hygiene, strict TypeScript, Biome, один coverage-backed запуск Vitest, Knip, +dependency rules и production build. Если backend-контракт изменился, +обновите `src/shared/api/spec/openapi.json` копированием backend OpenAPI +document и затем выполните `npm run openapi:generate`. ## Документация diff --git a/docs/API-LAYER.md b/docs/API-LAYER.md index 791aafe..863c1f2 100644 --- a/docs/API-LAYER.md +++ b/docs/API-LAYER.md @@ -1,8 +1,8 @@ # API Layer -OpenAPI backend является источником истины. После изменения backend-контракта запускается `npm run openapi:sync`; она получает опубликованный документ, валидирует генерацией и только затем атомарно обновляет `src/shared/api/spec/openapi.json` и `src/shared/api/generated/schema.d.ts`. Оба файла committed; generated файл не редактируется вручную. +OpenAPI backend является источником истины. После изменения backend-контракта committed snapshot `src/shared/api/spec/openapi.json` обновляют простым копированием опубликованного документа backend, а затем регенерируют `src/shared/api/generated/schema.d.ts` командой `npm run openapi:generate`. Оба файла committed; generated файл не редактируется вручную. -`npm run openapi:generate` и `npm run openapi:check` используют snapshot offline. `verify` никогда не требует backend; OpenAPI endpoint разрешён только явной sync-команде. +`npm run openapi:generate` использует committed snapshot offline. `verify` никогда не требует backend и не пытается автоматически синхронизировать OpenAPI artifacts. Все HTTP-вызовы используют `apiClient` и `apiQueryClient` из `src/shared/api`. Прямой `fetch` в features, pages и entities не используется. Query options берутся из `apiQueryClient`, а сохранённые cache keys и policies задаются вызывающим slice. diff --git a/package.json b/package.json index 5b831bf..6aa6524 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,9 @@ "test:run": "vitest run", "test:coverage": "vitest run --coverage", "openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts", - "openapi:sync": "node scripts/openapi-sync.mjs", - "openapi:check": "node scripts/openapi-check.mjs", "knip": "knip", "deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long", - "bundle:check": "node scripts/bundle-budget.mjs", - "verify": "npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run build && npm run bundle:check", + "verify": "npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run build", "prepare": "husky", "preview": "vite preview" }, diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs deleted file mode 100644 index 3c8288d..0000000 --- a/scripts/bundle-budget.mjs +++ /dev/null @@ -1,100 +0,0 @@ -import { statSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { pathToFileURL } from "node:url"; - -const INITIAL_BUNDLE_BUDGET_BYTES = 500 * 1024; - -function manifestEntries(manifest) { - if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { - throw new Error("Vite manifest must be an object"); - } - return Object.entries(manifest); -} - -function assetFile(entry, entryName) { - if (!entry || typeof entry.file !== "string" || !entry.file.endsWith(".js")) { - throw new Error( - `Manifest entry '${entryName}' must reference a JavaScript file`, - ); - } - return entry.file; -} - -function getInitialAssetFiles(manifest) { - const entries = manifestEntries(manifest); - const entryCandidates = entries.filter( - ([, entry]) => entry?.isEntry === true, - ); - if (entryCandidates.length !== 1) { - throw new Error("Vite manifest must contain exactly one JavaScript entry"); - } - - const files = []; - const visitedEntries = new Set(); - const visit = (entryName) => { - if (visitedEntries.has(entryName)) return; - visitedEntries.add(entryName); - const entry = manifest[entryName]; - if (!entry) - throw new Error(`Vite manifest is missing eager import '${entryName}'`); - files.push(assetFile(entry, entryName)); - for (const importedEntry of entry.imports ?? []) visit(importedEntry); - }; - - visit(entryCandidates[0][0]); - return files; -} - -function measureAssets(files, getSize) { - return files.reduce((total, file) => { - const size = getSize(file); - if (!Number.isSafeInteger(size) || size < 0) { - throw new Error(`Cannot measure bundle asset '${file}'`); - } - return total + size; - }, 0); -} - -function assertBundleBudget(initialBytes) { - if (initialBytes > INITIAL_BUNDLE_BUDGET_BYTES) { - throw new Error( - `Initial JavaScript payload is ${initialBytes} bytes; budget is ${INITIAL_BUNDLE_BUDGET_BYTES} bytes`, - ); - } -} - -const distributionDirectory = resolve("dist"); -const manifestPath = resolve(distributionDirectory, "bundle-manifest.json"); - -async function loadJson(path) { - return JSON.parse(await readFile(path, "utf8")); -} - -function getAssetSize(file) { - return statSync(resolve(distributionDirectory, file)).size; -} - -async function run() { - const manifest = await loadJson(manifestPath); - const initialBytes = measureAssets( - getInitialAssetFiles(manifest), - getAssetSize, - ); - assertBundleBudget(initialBytes); - console.log(`Initial JavaScript payload: ${initialBytes} bytes`); -} - -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { - await run(); -} - -export { - assertBundleBudget, - getInitialAssetFiles, - INITIAL_BUNDLE_BUDGET_BYTES, - measureAssets, -}; diff --git a/scripts/openapi-check.mjs b/scripts/openapi-check.mjs deleted file mode 100644 index 0026b21..0000000 --- a/scripts/openapi-check.mjs +++ /dev/null @@ -1,36 +0,0 @@ -import { spawn } from "node:child_process"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const snapshot = "src/shared/api/spec/openapi.json"; -const generated = "src/shared/api/generated/schema.d.ts"; -const directory = await mkdtemp(join(tmpdir(), "wealth-ledger-openapi-")); -const expected = join(directory, "schema.d.ts"); - -try { - await new Promise((resolve, reject) => { - const child = spawn( - "npx", - ["--no-install", "openapi-typescript", snapshot, "-o", expected], - { - stdio: "inherit", - }, - ); - child.on("error", reject); - child.on("exit", (code) => - code === 0 - ? resolve() - : reject(new Error(`openapi-typescript exited with ${code}`)), - ); - }); - if ( - (await readFile(expected, "utf8")) !== (await readFile(generated, "utf8")) - ) { - throw new Error( - "Generated OpenAPI types drift from src/shared/api/spec/openapi.json. Run npm run openapi:generate.", - ); - } -} finally { - await rm(directory, { recursive: true, force: true }); -} diff --git a/scripts/openapi-sync.mjs b/scripts/openapi-sync.mjs deleted file mode 100644 index 47cbc7d..0000000 --- a/scripts/openapi-sync.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import { spawn } from "node:child_process"; -import { mkdtemp, rename, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const baseUrl = process.env.VITE_API_BASE_URL ?? "http://localhost:3000"; -const snapshot = "src/shared/api/spec/openapi.json"; -const generated = "src/shared/api/generated/schema.d.ts"; -const directory = await mkdtemp(join(tmpdir(), "wealth-ledger-openapi-")); -const nextSnapshot = join(directory, "openapi.json"); -const nextGenerated = join(directory, "schema.d.ts"); - -try { - const response = await fetch(`${baseUrl}/api/docs-json`, { - headers: { Accept: "application/json" }, - }); - if (!response.ok) - throw new Error( - `OpenAPI sync failed: ${response.status} ${response.statusText}`, - ); - const document = await response.json(); - if (!document || typeof document !== "object") - throw new Error("OpenAPI sync failed: endpoint did not return an object"); - await writeFile(nextSnapshot, `${JSON.stringify(document, null, 2)}\n`); - await new Promise((resolve, reject) => { - const child = spawn( - "npx", - ["--no-install", "openapi-typescript", nextSnapshot, "-o", nextGenerated], - { - stdio: "inherit", - }, - ); - child.on("error", reject); - child.on("exit", (code) => - code === 0 - ? resolve() - : reject(new Error(`openapi-typescript exited with ${code}`)), - ); - }); - await rename(nextSnapshot, snapshot); - await rename(nextGenerated, generated); -} finally { - await rm(directory, { recursive: true, force: true }); -} diff --git a/vite.config.ts b/vite.config.ts index 82ab4fa..4c8a6bc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,5 +7,4 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [react(), babel({ presets: [reactCompilerPreset()] })], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } }, - build: { manifest: "bundle-manifest.json" }, }); diff --git a/vitest.config.ts b/vitest.config.ts index 4e76acf..bdf80f7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } }, test: { environment: "jsdom", - exclude: [...configDefaults.exclude, "scripts/**/*.test.mjs"], + exclude: configDefaults.exclude, setupFiles: "./src/test/setup.ts", coverage: { all: true,