#!/usr/bin/env node import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import process from "node:process"; const rootDir = process.cwd(); const routerPath = path.join(rootDir, "client/src/router/index.tsx"); const pagesDir = path.join(rootDir, "client/src/pages"); const findings = []; function toPosix(value) { return value.split(path.sep).join("/"); } function addFinding({ rule, file, line = 1, message, evidence }) { findings.push({ rule, file: toPosix(path.relative(rootDir, file)), line, message, evidence, }); } function lineNumber(source, index) { return source.slice(0, index).split("\n").length; } function collectFiles(dir) { if (!existsSync(dir)) return []; const files = []; for (const entry of readdirSync(dir)) { const fullPath = path.join(dir, entry); const stat = statSync(fullPath); if (stat.isDirectory()) { files.push(...collectFiles(fullPath)); continue; } if (/Mobile.*\.tsx$/.test(entry)) { files.push(fullPath); } } return files; } function extractRouteBlock(source, routePath) { const marker = `path="${routePath}"`; const start = source.indexOf(marker); if (start < 0) return null; const nextRoute = source.indexOf("\n = 0 ? nextRoute : source.indexOf("\n ", start); return { start, block: source.slice(start, end >= 0 ? end : start + 1200), }; } function scanRouter() { if (!existsSync(routerPath)) { addFinding({ rule: "mobile.router.exists", file: routerPath, message: "找不到 client/src/router/index.tsx", }); return; } const source = readFileSync(routerPath, "utf8"); const mobileRoutePaths = Array.from(source.matchAll(/path="(mobile[^"]*)"/g)).map((match) => match[1]); const routeComponents = []; for (const routePath of mobileRoutePaths) { const route = extractRouteBlock(source, routePath); if (!route) continue; const components = Array.from(route.block.matchAll(/<([A-Z][A-Za-z0-9]+Page)\s*\/>/g)).map((match) => match[1]); routeComponents.push({ routePath, components }); for (const component of components) { if (!component.startsWith("Mobile")) { addFinding({ rule: "mobile.router.no_pc_page", file: routerPath, line: lineNumber(source, route.start), message: `/mobile 路由不能直挂 PC 页面组件:${routePath} -> ${component}`, evidence: component, }); } } } const requiredRoutes = new Map([ ["mobile/profile", "MobileProfilePage"], ["mobile/notifications", "MobileNotificationsPage"], ["mobile/capture", "MobileCapturePage"], ["mobile/reimbursements", "MobileReimbursementsPage"], ["mobile/me", "MobileMePage"], ["mobile/approvals", "MobileApprovalsPage"], ]); for (const [routePath, expectedComponent] of requiredRoutes) { const route = routeComponents.find((item) => item.routePath === routePath); if (!route) { addFinding({ rule: "mobile.router.required_route", file: routerPath, message: `缺少企业微信移动入口路由:${routePath}`, }); continue; } if (!route.components.includes(expectedComponent)) { addFinding({ rule: "mobile.router.required_component", file: routerPath, line: lineNumber(source, source.indexOf(`path="${routePath}"`)), message: `${routePath} 应挂载 ${expectedComponent}`, evidence: route.components.join(", ") || "(none)", }); } } console.log("[mobile-route-contract] routes"); for (const item of routeComponents) { console.log(` ${item.routePath} -> ${item.components.join(", ") || "(no page component found)"}`); } } function scanMobilePages() { for (const file of collectFiles(pagesDir)) { const source = readFileSync(file, "utf8"); const patterns = [ { regex: /\bfrom ["']\.\.\/components\/ui\/table["']/g, rule: "mobile.page.no_table_component", message: "移动页面不应导入桌面表格组件。", }, { regex: /<\s*(?:Table|table)\b/g, rule: "mobile.page.no_visible_table_markup", message: "移动页面不应渲染桌面表格。", }, { regex: /page-shell-grid/g, rule: "mobile.page.no_pc_shell_grid", message: "移动页面不应使用 PC 页面双栏 shell 布局。", }, ]; for (const pattern of patterns) { let match = pattern.regex.exec(source); while (match) { addFinding({ rule: pattern.rule, file, line: lineNumber(source, match.index), message: pattern.message, evidence: match[0], }); match = pattern.regex.exec(source); } } } } scanRouter(); scanMobilePages(); if (findings.length) { console.log(`[mobile-route-contract] ${findings.length} finding(s)`); for (const finding of findings) { console.log(`ERROR ${finding.rule} ${finding.file}:${finding.line} ${finding.message}`); if (finding.evidence) { console.log(` evidence: ${finding.evidence}`); } } process.exitCode = 1; } else { console.log("[mobile-route-contract] no findings"); }