179 行
5.2 KiB
JavaScript
179 行
5.2 KiB
JavaScript
#!/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 <Route", start + marker.length);
|
|
const end = nextRoute >= 0 ? nextRoute : source.indexOf("\n </Route>", 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");
|
|
}
|