All checks were successful
Deploy / changes (push) Successful in 8s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy / build-frontend (push) Successful in 35s
Deploy Trade-In / build-frontend (push) Successful in 3m32s
Deploy Trade-In / deploy (push) Successful in 1m6s
Deploy / build-backend (push) Successful in 5m42s
Deploy / build-worker (push) Successful in 5m54s
Deploy / deploy (push) Successful in 1m47s
158 lines
6.2 KiB
JavaScript
158 lines
6.2 KiB
JavaScript
#!/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} файлов проверено, закрытый контур не найден.`,
|
||
);
|