From 3d9b6194c60b373996a590c4bd56653e0935e8c6 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 26 Aug 2026 23:13:57 +0300 Subject: [PATCH] chore: simplify frontend verification --- AGENTS.md | 2 +- README.md | 4 +- package.json | 9 +- scripts/bundle-budget.mjs | 58 +--------- scripts/bundle-budget.test.mjs | 85 --------------- scripts/dependency-cruiser-config.test.mjs | 117 --------------------- scripts/lazy-route-bundle-baseline.json | 11 -- scripts/repository-hygiene-check.mjs | 44 -------- scripts/verify-node-version.mjs | 28 ----- 9 files changed, 9 insertions(+), 349 deletions(-) delete mode 100644 scripts/bundle-budget.test.mjs delete mode 100644 scripts/dependency-cruiser-config.test.mjs delete mode 100644 scripts/lazy-route-bundle-baseline.json delete mode 100644 scripts/repository-hygiene-check.mjs delete mode 100644 scripts/verify-node-version.mjs diff --git a/AGENTS.md b/AGENTS.md index 1cea4dc..7d7c579 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ npm ci npm run verify ``` -Gate non-mutating и проверяет runtime, OpenAPI snapshot, hygiene, strict TypeScript, Biome, risk-based tests/coverage ratchet, Knip, dependency boundaries, build и bundle budget. `npm run openapi:sync` — отдельная записывающая команда для атомарного обновления snapshot/types; runtime contract обеих сторон сверяется из workspace через `bash specs/scripts/verify-runtime-openapi.sh`. +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`. Актуальные scripts проверять в `package.json`/`npm run`. ## Enforceable boundaries diff --git a/README.md b/README.md index db5088c..6dd7741 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,6 @@ snapshot. - [Remediation registry](../docs/development/audit-remediation.md) Runtime OpenAPI backend сверяется с frontend snapshot отдельной read-only -командой из workspace: `bash specs/scripts/verify-runtime-openapi.sh`. +backend командой `npm run test:openapi-contract`. Полный multi-repository change проверяется из workspace командой -`bash specs/scripts/verify-workspace-change.sh `. +native OpenSpec validation and the canonical gates of each affected repository. diff --git a/package.json b/package.json index a8f35fa..5b831bf 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "node": "22.x" }, "scripts": { - "runtime:check": "node scripts/verify-node-version.mjs", "dev": "vite", "typecheck": "tsc -b --noEmit", "build": "tsc -b && vite build", @@ -18,12 +17,10 @@ "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", - "hygiene:check": "node scripts/repository-hygiene-check.mjs", "knip": "knip", - "deps": "node --test scripts/dependency-cruiser-config.test.mjs && depcruise src --config dependency-cruiser.cjs --output-type err-long", - "bundle:check": "node --test scripts/bundle-budget.test.mjs && node scripts/bundle-budget.mjs", - "bundle:baseline": "npm run build && node scripts/bundle-budget.mjs baseline", - "verify": "npm run runtime:check && npm run openapi:check && npm run hygiene:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run build && npm run bundle:check", + "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", "prepare": "husky", "preview": "vite preview" }, diff --git a/scripts/bundle-budget.mjs b/scripts/bundle-budget.mjs index 8159c2a..3c8288d 100644 --- a/scripts/bundle-budget.mjs +++ b/scripts/bundle-budget.mjs @@ -1,5 +1,5 @@ import { statSync } from "node:fs"; -import { readFile, writeFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -64,41 +64,8 @@ function assertBundleBudget(initialBytes) { } } -function getLazyRouteAssetSizes(manifest, getSize, baseline) { - const routeEntries = manifestEntries(manifest).filter( - ([, entry]) => - entry?.isDynamicEntry === true && typeof entry.src === "string", - ); - if (routeEntries.length === 0) { - throw new Error("Vite manifest must contain lazy route chunks"); - } - - const routeSizes = Object.fromEntries( - routeEntries - .map(([entryName, entry]) => [ - entry.src, - getSize(assetFile(entry, entryName)), - ]) - .sort(([left], [right]) => left.localeCompare(right)), - ); - if (!baseline) return routeSizes; - - for (const [route, size] of Object.entries(routeSizes)) { - if (!Number.isSafeInteger(baseline[route])) { - throw new Error(`Missing baseline for lazy route chunk '${route}'`); - } - if (size > baseline[route]) { - throw new Error( - `Lazy route chunk '${route}' is ${size} bytes; baseline is ${baseline[route]} bytes`, - ); - } - } - return routeSizes; -} - const distributionDirectory = resolve("dist"); const manifestPath = resolve(distributionDirectory, "bundle-manifest.json"); -const baselinePath = resolve("scripts/lazy-route-bundle-baseline.json"); async function loadJson(path) { return JSON.parse(await readFile(path, "utf8")); @@ -108,27 +75,13 @@ function getAssetSize(file) { return statSync(resolve(distributionDirectory, file)).size; } -async function run(mode) { +async function run() { const manifest = await loadJson(manifestPath); const initialBytes = measureAssets( getInitialAssetFiles(manifest), getAssetSize, ); assertBundleBudget(initialBytes); - const baseline = - mode === "baseline" ? undefined : await loadJson(baselinePath); - const lazyRouteSizes = getLazyRouteAssetSizes( - manifest, - getAssetSize, - baseline, - ); - - if (mode === "baseline") { - await writeFile( - baselinePath, - `${JSON.stringify(lazyRouteSizes, null, "\t")}\n`, - ); - } console.log(`Initial JavaScript payload: ${initialBytes} bytes`); } @@ -136,17 +89,12 @@ if ( process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href ) { - const mode = process.argv[2] ?? "check"; - if (mode !== "check" && mode !== "baseline") { - throw new Error("Usage: node scripts/bundle-budget.mjs [check|baseline]"); - } - await run(mode); + await run(); } export { assertBundleBudget, getInitialAssetFiles, - getLazyRouteAssetSizes, INITIAL_BUNDLE_BUDGET_BYTES, measureAssets, }; diff --git a/scripts/bundle-budget.test.mjs b/scripts/bundle-budget.test.mjs deleted file mode 100644 index 82e1de9..0000000 --- a/scripts/bundle-budget.test.mjs +++ /dev/null @@ -1,85 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - assertBundleBudget, - getInitialAssetFiles, - getLazyRouteAssetSizes, - INITIAL_BUNDLE_BUDGET_BYTES, - measureAssets, -} from "./bundle-budget.mjs"; - -const manifest = { - "index.html": { - file: "assets/entry.js", - isEntry: true, - imports: ["_shared"], - dynamicImports: ["src/pages/account/ui/AccountPage.tsx"], - }, - _shared: { file: "assets/shared.js", imports: ["_runtime"] }, - _runtime: { file: "assets/runtime.js" }, - "src/pages/account/ui/AccountPage.tsx": { - file: "assets/account.js", - isDynamicEntry: true, - src: "src/pages/account/ui/AccountPage.tsx", - }, - "src/app/routes/ProtectedLayout.tsx": { - file: "assets/protected-layout.js", - isDynamicEntry: true, - src: "src/app/routes/ProtectedLayout.tsx", - }, -}; - -const sizes = new Map([ - ["assets/entry.js", 200 * 1024], - ["assets/shared.js", 175 * 1024], - ["assets/runtime.js", 100 * 1024], - ["assets/account.js", 250 * 1024], - ["assets/protected-layout.js", 50 * 1024], -]); - -test("measures only the eager import graph of the entry bundle", () => { - const assets = getInitialAssetFiles(manifest); - assert.deepEqual(assets, [ - "assets/entry.js", - "assets/shared.js", - "assets/runtime.js", - ]); - assert.equal( - measureAssets(assets, (asset) => sizes.get(asset)), - 475 * 1024, - ); -}); - -test("rejects an initial bundle above the configured budget", () => { - assert.throws( - () => assertBundleBudget(INITIAL_BUNDLE_BUDGET_BYTES + 1), - /JavaScript payload/, - ); -}); - -test("rejects malformed manifests and lazy-route size regressions", () => { - assert.throws(() => getInitialAssetFiles({}), /entry/); - assert.deepEqual( - getLazyRouteAssetSizes(manifest, (asset) => sizes.get(asset)), - { - "src/app/routes/ProtectedLayout.tsx": 50 * 1024, - "src/pages/account/ui/AccountPage.tsx": 250 * 1024, - }, - ); - assert.throws( - () => - getLazyRouteAssetSizes(manifest, (asset) => sizes.get(asset), { - "src/app/routes/ProtectedLayout.tsx": 50 * 1024, - "src/pages/account/ui/AccountPage.tsx": 249 * 1024, - }), - /route chunk/, - ); - assert.throws( - () => - getLazyRouteAssetSizes(manifest, (asset) => sizes.get(asset), { - "src/pages/account/ui/AccountPage.tsx": 250 * 1024, - }), - /Missing baseline/, - ); -}); diff --git a/scripts/dependency-cruiser-config.test.mjs b/scripts/dependency-cruiser-config.test.mjs deleted file mode 100644 index 570c841..0000000 --- a/scripts/dependency-cruiser-config.test.mjs +++ /dev/null @@ -1,117 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; - -const scriptsDirectory = dirname(fileURLToPath(import.meta.url)); -const projectDirectory = dirname(scriptsDirectory); -const dependencyCruiser = join( - projectDirectory, - "node_modules/dependency-cruiser/bin/dependency-cruise.mjs", -); -const dependencyConfig = join(projectDirectory, "dependency-cruiser.cjs"); - -async function withFixture(files, run) { - const fixtureDirectory = await mkdtemp(join(tmpdir(), "wealth-ledger-deps-")); - try { - await Promise.all( - Object.entries(files).map(async ([relativePath, contents]) => { - const filePath = join(fixtureDirectory, relativePath); - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, contents); - }), - ); - return await run(fixtureDirectory); - } finally { - await rm(fixtureDirectory, { recursive: true, force: true }); - } -} - -function cruise(fixtureDirectory) { - return spawnSync( - process.execPath, - [ - dependencyCruiser, - "src", - "--config", - dependencyConfig, - "--output-type", - "err-long", - ], - { cwd: fixtureDirectory, encoding: "utf8" }, - ); -} - -function output(result) { - return `${result.stdout}${result.stderr}`; -} - -test("rejects a source import cycle", async () => { - await withFixture( - { - "tsconfig.app.json": JSON.stringify({ compilerOptions: {} }), - "src/shared/i18n/index.ts": 'export { value } from "./zod";\n', - "src/shared/i18n/zod.ts": 'export { value } from ".";\n', - }, - (fixtureDirectory) => { - const result = cruise(fixtureDirectory); - assert.notEqual(result.status, 0, output(result)); - assert.match(output(result), /no-circular/); - }, - ); -}); - -test("rejects a deep import into another slice", async () => { - await withFixture( - { - "tsconfig.app.json": JSON.stringify({ compilerOptions: {} }), - "src/features/auth/ui/Form.ts": 'import "../../../shared/i18n/zod";\n', - "src/shared/i18n/index.ts": "export {};\n", - "src/shared/i18n/zod.ts": "export {};\n", - }, - (fixtureDirectory) => { - const result = cruise(fixtureDirectory); - assert.notEqual(result.status, 0, output(result)); - assert.match(output(result), /slices-must-use-public-api/); - }, - ); -}); - -test("rejects an index.tsx import from another slice", async () => { - await withFixture( - { - "tsconfig.app.json": JSON.stringify({ compilerOptions: {} }), - "src/features/auth/ui/Form.ts": 'import "../../../shared/i18n";\n', - "src/shared/i18n/index.tsx": "export {};\n", - }, - (fixtureDirectory) => { - const result = cruise(fixtureDirectory); - assert.notEqual(result.status, 0, output(result)); - assert.match(output(result), /slices-must-use-public-api/); - }, - ); -}); - -test("allows imports within a slice and through another slice public API", async () => { - await withFixture( - { - "tsconfig.app.json": JSON.stringify({ compilerOptions: {} }), - "src/features/auth/model/local.ts": "export const local = true;\n", - "src/features/auth/ui/Form.ts": [ - 'import { local } from "../model/local";\n', - 'import { value } from "../../../shared/i18n";\n', - "void local;\n", - "void value;\n", - ].join(""), - "src/shared/i18n/index.ts": 'export { value } from "./zod";\n', - "src/shared/i18n/zod.ts": "export const value = true;\n", - }, - (fixtureDirectory) => { - const result = cruise(fixtureDirectory); - assert.equal(result.status, 0, output(result)); - }, - ); -}); diff --git a/scripts/lazy-route-bundle-baseline.json b/scripts/lazy-route-bundle-baseline.json deleted file mode 100644 index c0de285..0000000 --- a/scripts/lazy-route-bundle-baseline.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "src/app/routes/ProtectedLayout.tsx": 3057, - "src/pages/account-events/index.ts": 4331, - "src/pages/account-operations/index.ts": 13473, - "src/pages/account/index.ts": 31277, - "src/pages/accounts/index.ts": 6205, - "src/pages/login/index.ts": 662, - "src/pages/not-found/index.ts": 877, - "src/pages/register/index.ts": 668, - "src/pages/settings/index.ts": 38980 -} diff --git a/scripts/repository-hygiene-check.mjs b/scripts/repository-hygiene-check.mjs deleted file mode 100644 index ac06ac9..0000000 --- a/scripts/repository-hygiene-check.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); - -function git(args) { - return execFileSync("git", args, { cwd: root, encoding: "utf8" }).trim(); -} - -function isIgnored(file) { - try { - execFileSync("git", ["check-ignore", "--no-index", "-q", file], { - cwd: root, - stdio: "ignore", - }); - return true; - } catch (error) { - if (error.status === 1) return false; - throw error; - } -} - -function assert(condition, message) { - if (!condition) throw new Error(message); -} - -const trackedEnvironmentFiles = git(["ls-files", "--", ".env", ".env.*"]) - .split("\n") - .filter(Boolean) - .sort(); - -assert( - JSON.stringify(trackedEnvironmentFiles) === JSON.stringify([".env.example"]), - `Only .env.example may be tracked; found: ${trackedEnvironmentFiles.join(", ") || "none"}`, -); - -for (const file of [".env", ".env.local", ".env.development", ".env.test"]) { - assert(isIgnored(file), `Local environment file must be ignored: ${file}`); -} - -assert(!isIgnored(".env.example"), ".env.example must remain versionable"); - -console.log("Frontend repository hygiene verified"); diff --git a/scripts/verify-node-version.mjs b/scripts/verify-node-version.mjs deleted file mode 100644 index 8b35138..0000000 --- a/scripts/verify-node-version.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const packageJson = JSON.parse( - readFileSync(resolve(root, "package.json"), "utf8"), -); -const nvmVersion = readFileSync(resolve(root, ".nvmrc"), "utf8").trim(); -const expectedMajor = nvmVersion.replace(/^v/, "").split(".")[0]; -const expectedEngine = `${expectedMajor}.x`; - -if (packageJson.engines?.node !== expectedEngine) { - throw new Error( - `Node runtime contracts disagree: .nvmrc=${nvmVersion}, package.json engines.node=${packageJson.engines?.node ?? "missing"}`, - ); -} - -const actualVersion = process.versions.node; -const actualMajor = actualVersion.split(".")[0]; -if (actualMajor !== expectedMajor) { - console.error( - `Unsupported Node.js runtime: expected ${expectedEngine}, actual ${actualVersion}. Activate .nvmrc before running repository checks.`, - ); - process.exit(1); -} - -console.log(`Node.js runtime verified: ${actualVersion} (${expectedEngine})`);