#!/usr/bin/env node /** * Guard for issue #2631 / #2545: публичный B2C-лэндинг `/mera-public` должен * оставаться "пустым по зависимостям" — ни один файл, который Next.js грузит * СТАТИЧЕСКИ (initial JS для этого route-чанка), не должен транзитивно * дотягиваться до закрытого контура (useMe / lib/api / lib/sessionId / * isPathAllowed / RBAC). Никакого test runner (jest/vitest) в проекте нет * (см. package.json) — это source-level guard вместо test-suite. * * НЕ подключён в `.forgejo/workflows/ci-tradein.yml` этим PR (вне scope — * только `tradein-mvp/frontend/**`). Чтобы он реально гейтил PR, нужен один * доп. `run: npm run check:mera-public-isolation` шаг в job `frontend-checks` * рядом с `npm run lint` — отдельным devops/frontend PR. * * Как это соотносится с реальным webpack-чанком: `RouteGuard.tsx` намеренно * подключает `GuardedRoute` через `next/dynamic(() => import(...))` — то есть * точку разрыва графа модулей (см. шапки RouteGuard.tsx / GuardedRoute.tsx). * Этот скрипт следует ТОЛЬКО статическим edges (`import ... from "..."` / * `export ... from "..."` / side-effect `import "..."`) и НИКОГДА не идёт * внутрь динамического `import(...)` — ровно то же правило, по которому * webpack решает, что попадёт в один чанк, а что уедет в отдельный. * * Запуск: `node scripts/check-mera-public-isolation.mjs` (npm run * check:mera-public-isolation). Exit 1 + печать цепочки импортов при находке. */ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const SRC = path.resolve(__dirname, "..", "src"); const RESOLVABLE_EXTS = [".ts", ".tsx", ".js", ".jsx"]; const STATIC_IMPORT_RE = /(?:^|\s)(?:import|export)\s[^;]*?\sfrom\s*["']([^"']+)["']|(?:^|\s)import\s*["']([^"']+)["']/g; function collectFilesRecursively(dir) { if (!existsSync(dir)) return []; const out = []; for (const entry of readdirSync(dir)) { const full = path.join(dir, entry); const st = statSync(full); if (st.isDirectory()) { out.push(...collectFilesRecursively(full)); } else if (RESOLVABLE_EXTS.includes(path.extname(full))) { out.push(full); } } return out; } // Всё, что Next.js грузит для /mera-public НЕЗАВИСИМО от dynamic()-разрывов: // корневой layout (Providers + RouteGuard) + всё поддерево лэндинга. const ENTRY_FILES = [ path.join(SRC, "app/layout.tsx"), path.join(SRC, "app/providers.tsx"), path.join(SRC, "components/auth/RouteGuard.tsx"), ...collectFilesRecursively(path.join(SRC, "app/mera-public")), ]; // Закрытый контур — см. GuardedRoute.tsx шапку. GuardedRoute.tsx сам тоже // forbidden: он обязан доезжать ТОЛЬКО через next/dynamic, никогда статически. const FORBIDDEN = [ "lib/useMe.ts", "lib/api.ts", "lib/sessionId.ts", "lib/isPathAllowed.ts", "components/auth/GuardedRoute.tsx", ].map((p) => path.join(SRC, p)); function resolveSpecifier(spec, fromFile) { let base; if (spec.startsWith("@/")) { base = path.join(SRC, spec.slice(2)); } else if (spec.startsWith(".")) { base = path.resolve(path.dirname(fromFile), spec); } else { return null; // bare specifier — внешний пакет / next/*, не наш граф } if (existsSync(base) && statSync(base).isFile()) return base; for (const ext of RESOLVABLE_EXTS) { if (existsSync(base + ext)) return base + ext; } for (const ext of RESOLVABLE_EXTS) { const indexed = path.join(base, "index" + ext); if (existsSync(indexed)) return indexed; } return null; // .css / .module.css / картинки / не найдено — лист, не ошибка } function staticImportsOf(file) { const src = readFileSync(file, "utf8"); const specs = []; let m; STATIC_IMPORT_RE.lastIndex = 0; while ((m = STATIC_IMPORT_RE.exec(src)) !== null) { specs.push(m[1] ?? m[2]); } return specs; } // BFS от всех entry-файлов по СТАТИЧЕСКИМ рёбрам, с parent-map для отчёта. const parent = new Map(); const visited = new Set(); const queue = []; for (const entry of ENTRY_FILES) { if (!visited.has(entry)) { visited.add(entry); queue.push(entry); } } let violation = null; while (queue.length > 0 && !violation) { const file = queue.shift(); for (const forbidden of FORBIDDEN) { if (file === forbidden) { violation = file; break; } } if (violation) break; for (const spec of staticImportsOf(file)) { const resolved = resolveSpecifier(spec, file); if (!resolved || visited.has(resolved)) continue; visited.add(resolved); parent.set(resolved, file); queue.push(resolved); } } function chainTo(file) { const chain = [file]; let cur = file; while (parent.has(cur)) { cur = parent.get(cur); chain.unshift(cur); } return chain.map((f) => path.relative(SRC, f)).join("\n -> "); } if (violation) { console.error( "FAIL: /mera-public публичный бандл статически дотягивается до закрытого контура.\n" + "Цепочка импортов:\n " + chainTo(violation) + "\n\nЕсли модуль реально нужен закрытому RBAC-коду — подключай его через " + "next/dynamic(() => import(...)), как GuardedRoute в RouteGuard.tsx, а не " + "статическим import.", ); process.exit(1); } console.log( `OK: mera-public isolation — ${ENTRY_FILES.length} файлов проверено, закрытый контур не найден.`, );