#!/usr/bin/env node /** * Аудит «сиротских» tenant-маршрутов: читает App.tsx, достаёт все * внутри tenant-блока, сравнивает со всеми строками, * до которых реально можно добраться кликами из UI — через sidebar * (AppLayout.tsx), командную палитру (CommandPalette.tsx), палитру * быстрых действий (QuickActionsPalette.tsx), SplitButtonMenu-экшены * на list-страницах (SuppliesPage.tsx, InventoriesPage.tsx). * * Сирота = tenant-маршрут, который объявлен в App.tsx, но не упомянут * ни в одном из входов и не относится к служебным (login/auth-bridge), * detail-страницам с :id (на них ведут клики по строкам таблицы), * страницам создания /new (на них ведут кнопки «Создать…»). * * Запуск: node tests/scripts/menu-orphans-audit.mjs * Опции: --json — машинный вывод для CI * --out= — вместо stdout пишет markdown-отчёт */ import { readFileSync, writeFileSync } from 'node:fs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' const __filename = fileURLToPath(import.meta.url) const ROOT = resolve(dirname(__filename), '..', '..') const WEB_SRC = resolve(ROOT, 'src/food-market.web/src') const files = { app: resolve(WEB_SRC, 'App.tsx'), layout: resolve(WEB_SRC, 'components/AppLayout.tsx'), cmdK: resolve(WEB_SRC, 'components/CommandPalette.tsx'), cmdJ: resolve(WEB_SRC, 'components/QuickActionsPalette.tsx'), supplies: resolve(WEB_SRC, 'pages/SuppliesPage.tsx'), inventories: resolve(WEB_SRC, 'pages/InventoriesPage.tsx'), } /** Все `path="…"` внутри tenant-ветки (под TenantRouteGuard). */ function extractTenantRoutes(appSrc) { const i = appSrc.indexOf('') const j = appSrc.indexOf('', appSrc.indexOf('. const slice = appSrc.slice(i) const paths = new Set() const re = /path="([^"]+)"/g let m while ((m = re.exec(slice))) { const p = m[1] if (p.startsWith('/')) paths.add(p) } return [...paths].sort() } /** Пути-строки из любого исходника. Regex-ы допускают пустую «часть * после слэша», чтобы ловить и голый `/` (корневой онбординг). */ function extractReachablePaths(src) { const out = new Set() // navigate('/foo') / navigate(`/foo`) for (const m of src.matchAll(/navigate\(\s*['"`](\/[^'"`\n]*)['"`]/g)) out.add(m[1]) // to="/foo" for (const m of src.matchAll(/\bto="(\/[^"]*)"/g)) out.add(m[1]) for (const m of src.matchAll(/\bto=\{\s*['"`](\/[^'"`]*)['"`]\s*\}/g)) out.add(m[1]) // href="/foo" for (const m of src.matchAll(/\bhref="(\/[^"]*)"/g)) out.add(m[1]) // Поля объектов (SplitButtonMenu / STATIC_PAGES / NavItem). for (const m of src.matchAll(/\bto:\s*['"`](\/[^'"`]*)['"`]/g)) out.add(m[1]) for (const m of src.matchAll(/\bpath:\s*['"`](\/[^'"`]*)['"`]/g)) out.add(m[1]) // run: (n) => n('/foo') for (const m of src.matchAll(/\bn\(\s*['"`](\/[^'"`]*)['"`]/g)) out.add(m[1]) return out } /** Служебные / «вспомогательные» маршруты, которые заведомо не должны * быть пунктом меню. */ function isReservedRoute(p) { // Детали редактирования — туда ведут клики по строкам таблиц. if (/\/:[^/]+(\?)?$/.test(p)) return true // «Создать новое …» — туда ведут кнопки «Новый …», это ок. if (p.endsWith('/new')) return true // Явный служебный набор. if (['/login', '/forgot-password', '/reset-password', '/auth-bridge', '/no-organization', '/onboarding-wizard'].includes(p)) return true // SuperAdmin консоль — отдельный layout, у неё свой sidebar. if (p.startsWith('/super-admin')) return true return false } function main() { const appSrc = readFileSync(files.app, 'utf8') const tenantRoutes = extractTenantRoutes(appSrc) const reachable = new Set() for (const key of ['layout', 'cmdK', 'cmdJ', 'supplies', 'inventories']) { const src = readFileSync(files[key], 'utf8') for (const p of extractReachablePaths(src)) reachable.add(p) } const orphans = [] const covered = [] const reserved = [] for (const p of tenantRoutes) { if (isReservedRoute(p)) { reserved.push(p); continue } if (reachable.has(p)) { covered.push(p); continue } // Также засчитываем, если это list-страница, которая достижима через // /... → /.../new (например, когда есть «Создать» на пустой странице, // но нет точки входа на сам список) — это ВСЁ РАВНО сирота: пустую // кнопку создать можно, но вернуться к списку нельзя. orphans.push(p) } const argv = process.argv.slice(2) const asJson = argv.includes('--json') const outArg = argv.find((a) => a.startsWith('--out=')) const outFile = outArg ? outArg.slice('--out='.length) : null if (asJson) { const payload = { tenantRoutes, reachable: [...reachable].sort(), orphans, covered, reserved } process.stdout.write(JSON.stringify(payload, null, 2) + '\n') process.exit(orphans.length > 0 ? 2 : 0) } const lines = [] lines.push('# Menu orphans audit') lines.push('') lines.push(`Generated: ${new Date().toISOString()}`) lines.push('') lines.push(`- tenant-маршрутов всего: **${tenantRoutes.length}**`) lines.push(`- служебных (login / :id / /new / super-admin): **${reserved.length}**`) lines.push(`- достижимо кликами: **${covered.length}**`) lines.push(`- сирот (не достижимо из UI): **${orphans.length}**`) lines.push('') if (orphans.length) { lines.push('## Сироты') lines.push('') for (const p of orphans) lines.push(`- \`${p}\``) lines.push('') } else { lines.push('## Сироты') lines.push('') lines.push('_Нет._ Все не-служебные tenant-маршруты достижимы через sidebar / Cmd+K / Cmd+J / кнопки на list-страницах.') lines.push('') } lines.push('## Достижимо') lines.push('') for (const p of covered) lines.push(`- \`${p}\``) lines.push('') lines.push('## Служебное (не требует точки входа)') lines.push('') for (const p of reserved) lines.push(`- \`${p}\``) lines.push('') const out = lines.join('\n') if (outFile) { writeFileSync(outFile, out, 'utf8') process.stderr.write(`[orphans] wrote ${outFile} — ${orphans.length} orphan(s)\n`) } else { process.stdout.write(out) } process.exit(orphans.length > 0 ? 2 : 0) } main()