/** * 路由审计脚本:交叉校验代码中引用的路由 vs app/ 下实际存在的路由文件。 * 用法:node scripts/check-routes.mjs */ import fs from 'fs'; import path from 'path'; const appDir = path.resolve('app'); const routeFiles = []; function walk(dir, rel = '') { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, e.name); const relPath = rel ? rel + '/' + e.name : e.name; if (e.isDirectory()) walk(full, relPath); else if (/\.(tsx|ts|jsx|js)$/.test(e.name)) routeFiles.push(relPath); } } walk(appDir); function toRoute(file) { let r = file.replace(/\.(tsx|ts|jsx|js)$/, ''); r = r.split('/').filter((s) => !/^\(.*\)$/.test(s)).join('/'); r = r.replace(/\/index$/, ''); return '/' + r.replace(/_layout$/, '').replace(/\/$/, ''); } const actualRoutes = new Set(routeFiles.map(toRoute).filter(Boolean)); function normalize(t) { const clean = t.split('?')[0].replace(/\/$/, ''); // 去掉路由组前缀:expo-router 允许写 "/(tabs)/jobs" 这类完整路径 return clean.split('/').filter((s) => s && !/^\(.*\)$/.test(s)).join('/'); } function routeExists(target) { const clean = target.split('?')[0].replace(/\/$/, ''); if (clean === '' || clean === 'index') return true; if (actualRoutes.has(clean)) return true; const n = normalize(clean); for (const r of actualRoutes) { const rn = normalize(r); if (rn === n) return true; const pattern = '^' + rn.replace(/\[\[?\.\.\.(\w+)\]\]?/g, '.+').replace(/\[(\w+)\]/g, '[^/]+') + '$'; if (new RegExp(pattern).test(n)) return true; } return false; } const srcDirs = ['app', 'components', 'src']; const files = []; function walkSrc(dir) { if (!fs.existsSync(dir)) return; for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, e.name); if (e.isDirectory()) walkSrc(full); else if (/\.(tsx|ts)$/.test(e.name)) files.push(full); } } srcDirs.forEach(walkSrc); const routesFound = new Map(); const reLocal = /['"`](\/[a-zA-Z0-9_()\-[\]/?=&.][a-zA-Z0-9_()\-[\]/?=&.]*)['"`]/g; for (const f of files) { const code = fs.readFileSync(f, 'utf8'); let m; while ((m = reLocal.exec(code)) !== null) { const r = m[1]; if (r.startsWith('//')) continue; if (!routesFound.has(r)) routesFound.set(r, []); routesFound.get(r).push(path.relative(process.cwd(), f).replace(/\\/g, '/')); } } console.log('=== 实际存在的路由 (' + actualRoutes.size + ') ==='); console.log([...actualRoutes].sort().join('\n')); console.log('\n=== 引用但不存在的路由 ==='); let bad = 0; for (const [r, srcs] of [...routesFound.entries()].sort()) { if (r.includes('${')) continue; if (/\.(png|jpg|jpeg|svg|json|ttf|css)$/i.test(r)) continue; if (r.startsWith('/node_modules')) continue; if (!routeExists(r)) { console.log(`${r} <-- ${[...new Set(srcs)].join(', ')}`); bad++; } } console.log('\n不存在的路由数量: ' + bad); // 反向:孤立页面(没有任何入口指向) console.log('\n=== 无入口指向的孤立页面 ==='); const referenced = new Set(); function markRef(r) { referenced.add(r); referenced.add('/' + normalize(r)); // 动态拼接的路径(如 '/chat/' + id)按前缀登记,前缀命中即视为已引用 const n = normalize(r); const seg = n.split('/'); if (seg.length > 1) referenced.add('/' + seg.slice(0, seg.length - 1).join('/')); } for (const r of routesFound.keys()) markRef(r); const orphans = [...actualRoutes].filter((r) => !referenced.has(r) && !referenced.has('/' + normalize(r))); console.log(orphans.sort().join('\n') || '(无)');