#!/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 strict = process.argv.includes("--strict"); const jsonOutput = process.argv.includes("--json"); const selfTest = process.argv.includes("--self-test"); const showSuppressed = process.argv.includes("--show-suppressed"); const scanRoots = [ "client/src/components", "client/src/features/navigation", "client/src/layouts", "client/src/pages", ]; const sourceExtensions = new Set([".js", ".jsx", ".ts", ".tsx"]); const forbiddenCopy = ["先处理", "清爽"]; const forbiddenCompetitorReferences = [ "飞书", "钉钉", "泛微", "每刻", "合思", "易快报", "金蝶", "用友", "Ant Design", "WeUI", "e-office", "e-cology", ]; const forbiddenDesignMetaCopy = ["照搬"]; const userFacingBusinessTermFiles = new Set([ "client/src/pages/MobileCapturePage.tsx", "client/src/pages/MobileNotificationsPage.tsx", "client/src/pages/MobileProfilePage.tsx", "client/src/pages/MobileReimbursementDetailPage.tsx", "client/src/pages/ReimbursementDetailPage.tsx", "client/src/components/travel/TravelPaymentTrackCard.tsx", ]); const forbiddenUserFacingBusinessTerms = ["绑定整单", "不绑定明细", "发票池", "OCR"]; const findings = []; const suppressedFindings = []; const ruleAllowlist = [ { rule: "data.no_uuid_primary_display", filePattern: /^client\/src\/pages\/(?:AuditLogs|UserManagement|Departments|Delegations|ApprovalRules|WorkflowDefinitions|ErpIntegration|FeatureFlags|CutoverConsole|MasterData|Budgets|Payroll|Performance|People|EmployeeProfile|Organization|ReimbursementPrint)Page\.tsx$/, reason: "管理员、审计或打印留痕页面允许展示内部记录 ID。", }, ]; function toPosix(value) { return value.split(path.sep).join("/"); } function collectFiles(dir) { if (!existsSync(dir)) { return []; } const entries = readdirSync(dir); const files = []; for (const entry of entries) { const fullPath = path.join(dir, entry); const stat = statSync(fullPath); if (stat.isDirectory()) { files.push(...collectFiles(fullPath)); continue; } if (sourceExtensions.has(path.extname(fullPath))) { files.push(fullPath); } } return files; } function lineNumber(source, index) { return source.slice(0, index).split("\n").length; } function normalizeFindingFile(file) { return toPosix(path.isAbsolute(file) ? path.relative(rootDir, file) : file); } function findSuppression(finding) { return ruleAllowlist.find((item) => item.rule === finding.rule && item.filePattern.test(finding.file)); } function addFinding({ rule, severity = "warning", file, line = 1, message, evidence }) { const finding = { rule, severity, file: normalizeFindingFile(file), line, message, evidence, }; const suppression = findSuppression(finding); if (suppression) { suppressedFindings.push({ ...finding, suppressionReason: suppression.reason }); return; } findings.push(finding); } function scanForbiddenCopy(file, source) { for (const phrase of forbiddenCopy) { let start = source.indexOf(phrase); while (start !== -1) { addFinding({ rule: "copy.no_conversational_labels", severity: "error", file, line: lineNumber(source, start), message: `避免口语化或内部评价文案:${phrase}`, evidence: phrase, }); start = source.indexOf(phrase, start + phrase.length); } } } function scanCompetitorReferenceCopy(file, source) { for (const phrase of forbiddenCompetitorReferences) { let start = source.indexOf(phrase); while (start !== -1) { addFinding({ rule: "copy.no_competitor_reference", severity: "error", file, line: lineNumber(source, start), message: `用户界面不得出现竞品或外部设计系统名称:${phrase}`, evidence: phrase, }); start = source.indexOf(phrase, start + phrase.length); } } for (const phrase of forbiddenDesignMetaCopy) { let start = source.indexOf(phrase); while (start !== -1) { addFinding({ rule: "copy.no_design_meta_labels", severity: "error", file, line: lineNumber(source, start), message: `用户界面不得出现设计治理元话语:${phrase}`, evidence: phrase, }); start = source.indexOf(phrase, start + phrase.length); } } } function scanMobileDarkControls(file, source) { const normalized = toPosix(file); const mobileScope = /\/Mobile|\/mobile\//.test(normalized) || /mobile/i.test(path.basename(file)); if (!mobileScope || normalized.endsWith("client/src/components/mobile/BarcodeScannerPanel.tsx")) { return; } const darkClassPattern = /\bbg-(?:black|zinc-900|slate-900|neutral-900|stone-900|gray-900)\b/g; let match = darkClassPattern.exec(source); while (match) { addFinding({ rule: "color.no_mobile_black_primary", severity: "error", file, line: lineNumber(source, match.index), message: "移动端主控件、卡片或底部导航不应使用黑色大块背景。", evidence: match[0], }); match = darkClassPattern.exec(source); } } function scanNativeFileInputs(file, source) { const fileInputPattern = /]*\btype=["']file["'][^>]*>/g; let match = fileInputPattern.exec(source); while (match) { if (/\bhidden\b/.test(match[0]) || /\bsr-only\b/.test(match[0])) { match = fileInputPattern.exec(source); continue; } addFinding({ rule: "form.no_native_file_input_visible", severity: "warning", file, line: lineNumber(source, match.index), message: "上传控件应提供中文按钮和文件名回显,避免暴露浏览器原生 Choose File 文案。", evidence: match[0].replace(/\s+/g, " ").slice(0, 140), }); match = fileInputPattern.exec(source); } } function scanVisibleEnglishUploadCopy(file, source) { const patterns = [ { regex: />\s*(Choose File|No file chosen|Upload|Uploading|Browse)\s*]*\btype=["']file["'][^>]*>/g; let match = fileInputPattern.exec(source); while (match) { const inputSource = match[0]; if (/application\/pdf/i.test(inputSource) && /\bcapture\b/i.test(inputSource)) { addFinding({ rule: "form.no_pdf_capture_input", severity: "error", file, line: lineNumber(source, match.index), message: "PDF 上传入口不能带 capture,否则企业微信容易直接打开摄像头。", evidence: inputSource.replace(/\s+/g, " ").slice(0, 160), }); } match = fileInputPattern.exec(source); } } function scanUserFacingBusinessTerms(file, source) { const normalized = toPosix(path.relative(rootDir, file)); if (!userFacingBusinessTermFiles.has(normalized)) { return; } for (const term of forbiddenUserFacingBusinessTerms) { let start = source.indexOf(term); while (start !== -1) { addFinding({ rule: "business.no_internal_invoice_terms", severity: "error", file, line: lineNumber(source, start), message: `普通用户主流程不应暴露内部发票术语:${term}`, evidence: term, }); start = source.indexOf(term, start + term.length); } } } function isMobileSourceFile(file) { const normalized = normalizeFindingFile(file); return /^client\/src\/pages\/Mobile.*\.tsx$/.test(normalized) || /^client\/src\/components\/mobile\/.*\.(?:tsx|ts)$/.test(normalized); } function scanMobileDesktopTables(file, source) { if (!isMobileSourceFile(file)) { return; } const patterns = [ { regex: /<\s*table\b| match[1]); } function scanNavigationTitleCoverage(file, source) { if (!toPosix(file).endsWith("client/src/features/navigation/schema.ts")) { return; } const navPaths = new Set([ ...extractStringList(source, "MAIN_NAV_ORDER"), ...extractStringList(source, "ADMIN_NAV_ORDER"), ...extractStringList(source, "BACKUP_NAV_ORDER"), ]); const titlePaths = new Set(Array.from(source.matchAll(/\{\s*path:\s*["']([^"']+)["']/g)).map((match) => match[1])); for (const navPath of navPaths) { const covered = Array.from(titlePaths).some((titlePath) => navPath === titlePath || navPath.startsWith(`${titlePath}/`)); if (covered) { continue; } const index = source.indexOf(`"${navPath}"`); addFinding({ rule: "navigation.no_route_title_fallback", severity: "warning", file, line: index >= 0 ? lineNumber(source, index) : 1, message: `导航路径 ${navPath} 缺少 PAGE_TITLES 显式标题,可能在顶栏显示路由。`, evidence: navPath, }); } } const reimbursementWorkspaceShellFiles = new Set([ "client/src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx", "client/src/components/reimbursements/ReimbursementMobileWorkspaceShell.tsx", ]); function scanReimbursementWorkspaceShellBoundary(file, source) { const normalized = normalizeFindingFile(file); if (!reimbursementWorkspaceShellFiles.has(normalized)) { return; } const patterns = [ { regex: /\buseQuery\b/g, evidence: "useQuery" }, { regex: /\buseMutation\b/g, evidence: "useMutation" }, { regex: /(?:from\s+["'][^"']*\/api\/|\bapiClient\b|\bapi\.(?:get|post|put|patch|delete)\b)/g, evidence: "API client" }, { regex: /\bfetch\s*\(/g, evidence: "fetch" }, { regex: /\b(?:hasPermission|roleCodes?|permissionCodes?)\b/g, evidence: "permission/role" }, { regex: /\b(?:workflowStatus|reimbursementStatus)\b/g, evidence: "business status" }, { regex: /\bmode\s*(?:={2,3}|!==?)\s*["']/g, evidence: "mode comparison" }, { regex: /\bisMobile\b/g, evidence: "isMobile branch" }, ]; for (const pattern of patterns) { let match = pattern.regex.exec(source); while (match) { addFinding({ rule: "architecture.reimbursement_workspace_shell_boundary", severity: "error", file, line: lineNumber(source, match.index), message: "报销工作台布局壳只能组合已计算模型和能力,不得查询 API、判断角色/业务模式或用移动端分支推导行为。", evidence: pattern.evidence, }); match = pattern.regex.exec(source); } } } function scanLegacyWorkflowReimbursementBranch(file, source) { const normalized = normalizeFindingFile(file); if (!["client/src/pages/WorkflowTaskDetailPage.tsx", "client/src/pages/MobileApprovalsPage.tsx"].includes(normalized)) { return; } const patterns = [ /\bReimbursementItemEvidenceList\b/g, /\b(?:isReimbursementTask|isSelectedReimbursement|reimbursementDecisionReady|reimbursementEvidence|reimbursementDetailQuery)\b/g, /\bbuildReimbursementReturnReference\b/g, /\bselectedTask\?*\.reimbursement\b/g, /data-testid=["']reimbursement-approval-(?:evidence|checks)["']/g, /(?:提交时检查|当前票据检查|打开完整原始表单)/g, ]; for (const regex of patterns) { let match = regex.exec(source); while (match) { addFinding({ rule: "architecture.no_legacy_workflow_reimbursement_branch", severity: "error", file, line: lineNumber(source, match.index), message: "通用审批详情不得保留报销证据、检查或决策就绪分支;报销只能在授权分派层进入工作台适配器。", evidence: match[0], }); match = regex.exec(source); } } } function scanFileSource(file, source) { scanForbiddenCopy(file, source); scanCompetitorReferenceCopy(file, source); scanUserFacingBusinessTerms(file, source); scanMobileDarkControls(file, source); scanNativeFileInputs(file, source); scanVisibleEnglishUploadCopy(file, source); scanMobileCapturePdfInputs(file, source); scanMobileDesktopTables(file, source); scanMobilePcShell(file, source); scanMobileDesktopContainers(file, source); scanUuidPrimaryDisplay(file, source); scanRawBusinessIdentifiers(file, source); scanReimbursementRawCodeDisplay(file, source); scanDuplicateBusinessIdentifierDisplay(file, source); scanNavigationTitleCoverage(file, source); scanReimbursementWorkspaceShellBoundary(file, source); scanLegacyWorkflowReimbursementBranch(file, source); } function scanFile(file) { scanFileSource(file, readFileSync(file, "utf8")); } function runSelfTest() { const beforeFindings = findings.length; const beforeSuppressed = suppressedFindings.length; const cases = [ { file: "client/src/pages/MobileBadFixture.tsx", source: ` export function MobileBadFixture() { return
任务ID:{task.id}
业务单号:{task.reference}
参考飞书布局照搬
; } `, expectedRules: [ "mobile.no_desktop_table", "mobile.no_pc_shell", "copy.no_english_upload_labels", "copy.no_competitor_reference", "copy.no_design_meta_labels", "data.no_uuid_primary_display", "display.no_duplicate_business_identifier", ], expectedSuppressedRules: [], }, { file: "client/src/pages/MobileDesktopCardFixture.tsx", source: ` export function MobileDesktopCardFixture() { return
内容
; } `, expectedRules: ["mobile.no_desktop_card_container"], expectedSuppressedRules: [], }, { file: "client/src/pages/AuditLogsPage.tsx", source: ` export function AuditLogs() { return
记录ID:{item.id}
; } `, expectedRules: [], expectedSuppressedRules: ["data.no_uuid_primary_display"], }, { file: "client/src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx", source: ` import { useQuery } from "@tanstack/react-query"; export function ReimbursementDesktopWorkspaceShell({ mode, roleCodes }) { const query = useQuery({ queryKey: ["bad"] }); void fetch("/api/reimbursements/bad"); return mode === "approval" && roleCodes ?
{query.data}
: null; } `, expectedRules: ["architecture.reimbursement_workspace_shell_boundary"], expectedSuppressedRules: [], }, { file: "client/src/pages/WorkflowTaskDetailPage.tsx", source: ` import { ReimbursementItemEvidenceList } from "../components/reimbursements/ReimbursementItemEvidenceList"; export function Fixture({ selectedTask }) { return selectedTask?.reimbursement ? <>提交时检查 : null; } `, expectedRules: ["architecture.no_legacy_workflow_reimbursement_branch"], expectedSuppressedRules: [], }, ]; for (const testCase of cases) { scanFileSource(testCase.file, testCase.source); const caseFindings = findings.slice(beforeFindings).filter((finding) => finding.file === testCase.file); const caseSuppressed = suppressedFindings.slice(beforeSuppressed).filter((finding) => finding.file === testCase.file); for (const expectedRule of testCase.expectedRules) { if (!caseFindings.some((finding) => finding.rule === expectedRule)) { throw new Error(`[ui-design-rules][self-test] ${testCase.file} 未命中规则 ${expectedRule}`); } } for (const expectedRule of testCase.expectedSuppressedRules) { if (!caseSuppressed.some((finding) => finding.rule === expectedRule && finding.suppressionReason)) { throw new Error(`[ui-design-rules][self-test] ${testCase.file} 未命中白名单 ${expectedRule}`); } } } findings.splice(beforeFindings); suppressedFindings.splice(beforeSuppressed); console.log("[ui-design-rules][self-test] PASS"); } if (selfTest) { runSelfTest(); process.exit(0); } const files = scanRoots.flatMap((scanRoot) => collectFiles(path.join(rootDir, scanRoot))); for (const file of files) { scanFile(file); } const summary = { scannedFiles: files.length, findings: findings.length, errors: findings.filter((finding) => finding.severity === "error").length, warnings: findings.filter((finding) => finding.severity === "warning").length, suppressed: suppressedFindings.length, }; if (jsonOutput) { console.log(JSON.stringify({ summary, findings, suppressedFindings }, null, 2)); } else { console.log(`[ui-design-rules] scanned ${summary.scannedFiles} files, ${summary.findings} findings (${summary.errors} errors, ${summary.warnings} warnings, ${summary.suppressed} suppressed)`); for (const finding of findings) { console.log(`${finding.severity.toUpperCase()} ${finding.rule} ${finding.file}:${finding.line} ${finding.message}`); if (finding.evidence) { console.log(` evidence: ${finding.evidence}`); } } if (showSuppressed) { for (const finding of suppressedFindings) { console.log(`SUPPRESSED ${finding.rule} ${finding.file}:${finding.line} ${finding.suppressionReason}`); if (finding.evidence) { console.log(` evidence: ${finding.evidence}`); } } } if (!findings.length) { console.log("[ui-design-rules] no findings"); } if (findings.length && !strict) { console.log("[ui-design-rules] diagnostic mode: findings are reported but do not fail. Use --strict to enforce."); } } if (strict && findings.length > 0) { process.exitCode = 1; }