#!/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()