// Route table consistency check (ARCHITECTURE.md §10 P1-8) // // Verifies that every actual /shell/* page.tsx route is registered in // route-permissions.ts (EXACT / PREFIX / DASHBOARD / PUBLIC_ROUTES). // Catches "unregistered routes" that would fall through to the catch-all // and be denied by middleware (fail-closed) — developers get a clear CI // error instead of a confusing runtime 403. // // Also reports "ghost entries" (EXACT table entries without a page.tsx) // as informational output — these are planned future routes (P2-P5). // // Usage: tsx scripts/check-route-table.ts // Exit: 0 = pass, 1 = violations found // // Related: ARCHITECTURE.md §3.4 V3-A1, §5.3, §10 P1-8, §11.7 红线 #5 import * as fs from "node:fs"; import * as path from "node:path"; const APP_DIR = path.resolve(process.cwd(), "src/app"); const ROUTE_PERMS_FILE = path.resolve( process.cwd(), "src/shared/lib/route-permissions.ts", ); interface Violation { type: "unregistered_route" | "ghost_entry"; route: string; detail: string; } function scanActualRoutes(): Set { const routes = new Set(); function walk(dir: string, prefix: string): void { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { // Skip catch-all [[...route]] directory if (entry.name.startsWith("[[")) continue; walk(full, `${prefix}/${entry.name}`); } else if (entry.name === "page.tsx") { routes.add(prefix || "/"); } } } walk(APP_DIR, ""); return routes; } function extractRegisteredRoutes(): { exact: Set; prefixes: string[]; publicRoutes: Set; } { const content = fs.readFileSync(ROUTE_PERMS_FILE, "utf8"); const exact = new Set(); const prefixes: string[] = []; const publicRoutes = new Set(); const routeKeyRe = new RegExp('"(/[^"]*?)":\\s*\\{', "g"); const prefixRe = new RegExp('prefix:\\s*"(/[^"]*?)"', "g"); let m: RegExpExecArray | null; while ((m = routeKeyRe.exec(content)) !== null) { exact.add(m[1]); } while ((m = prefixRe.exec(content)) !== null) { prefixes.push(m[1]); } // PUBLIC_ROUTES array entries (skip past `readonly string[] =` to the real `[`) const publicBlock = content.match( new RegExp("PUBLIC_ROUTES[^=]*=\\s*\\[([\\s\\S]*?)\\]"), )?.[1]; if (publicBlock) { const re = new RegExp('"(/[^"]*?)"', "g"); while ((m = re.exec(publicBlock)) !== null) { publicRoutes.add(m[1]); } } return { exact, prefixes, publicRoutes }; } function isRegistered( route: string, exact: Set, prefixes: string[], publicRoutes: Set, ): boolean { if (publicRoutes.has(route)) return true; if (exact.has(route)) return true; for (const p of prefixes) { if (route.startsWith(p)) return true; } return false; } function main(): void { const actualRoutes = scanActualRoutes(); const { exact, prefixes, publicRoutes } = extractRegisteredRoutes(); const violations: Violation[] = []; // Check A: every actual /shell/* route must be registered for (const route of [...actualRoutes].sort()) { if (!route.startsWith("/shell")) continue; if (!isRegistered(route, exact, prefixes, publicRoutes)) { violations.push({ type: "unregistered_route", route, detail: "page.tsx exists but route not in EXACT/PREFIX/DASHBOARD/PUBLIC_ROUTES", }); } } // Check B (informational): ghost entries (EXACT entries without page.tsx) const ghostEntries: string[] = []; for (const entry of [...exact].sort()) { if (!entry.startsWith("/shell/")) continue; if (!actualRoutes.has(entry)) { ghostEntries.push(entry); } } // Report console.log("=== Route Table Consistency Check ==="); console.log( `Actual /shell/* routes: ${[...actualRoutes].filter((r) => r.startsWith("/shell")).length}`, ); console.log( `EXACT entries: ${[...exact].filter((r) => r.startsWith("/shell/")).length}`, ); console.log(`PREFIX entries: ${prefixes.length}`); console.log(`PUBLIC_ROUTES: ${publicRoutes.size}`); console.log(""); if (violations.length > 0) { console.log("❌ VIOLATIONS (unregistered routes):"); for (const v of violations) { console.log(` ${v.route} — ${v.detail}`); } console.log(""); } if (ghostEntries.length > 0) { console.log( `ℹ️ GHOST ENTRIES (planned, no page.tsx yet): ${ghostEntries.length}`, ); for (const g of ghostEntries) { console.log(` ${g}`); } console.log(""); } if (violations.length > 0) { console.log(`Result: FAIL (${violations.length} violation(s))`); process.exit(1); } else { console.log( `Result: PASS (0 violations, ${ghostEntries.length} ghost entries)`, ); process.exit(0); } } main();