#!/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|