569 行
18 KiB
JavaScript
569 行
18 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 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 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 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 = /<input\b[^>]*\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*</g,
|
||
message: "上传相关可见文案必须使用中文,避免暴露浏览器原生英文上传体验。",
|
||
},
|
||
{
|
||
regex: /(?:aria-label|title|placeholder)=["'](Choose File|No file chosen|Upload|Uploading|Browse)["']/g,
|
||
message: "上传相关 aria/title/placeholder 文案必须使用中文。",
|
||
},
|
||
];
|
||
|
||
for (const pattern of patterns) {
|
||
let match = pattern.regex.exec(source);
|
||
while (match) {
|
||
addFinding({
|
||
rule: "copy.no_english_upload_labels",
|
||
severity: "error",
|
||
file,
|
||
line: lineNumber(source, match.index),
|
||
message: pattern.message,
|
||
evidence: match[0].replace(/\s+/g, " ").slice(0, 140),
|
||
});
|
||
match = pattern.regex.exec(source);
|
||
}
|
||
}
|
||
}
|
||
|
||
function scanMobileCapturePdfInputs(file, source) {
|
||
const normalized = toPosix(path.relative(rootDir, file));
|
||
if (!/client\/src\/pages\/Mobile.*\.tsx$/.test(normalized)) {
|
||
return;
|
||
}
|
||
|
||
const fileInputPattern = /<input\b[^>]*\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|<Table\b/g,
|
||
evidence: "table",
|
||
},
|
||
{
|
||
regex: /\btable-container\b|components\/ui\/table/g,
|
||
evidence: "table-container/ui-table",
|
||
},
|
||
];
|
||
|
||
for (const pattern of patterns) {
|
||
let match = pattern.regex.exec(source);
|
||
while (match) {
|
||
addFinding({
|
||
rule: "mobile.no_desktop_table",
|
||
severity: "error",
|
||
file,
|
||
line: lineNumber(source, match.index),
|
||
message: "专用 /mobile 页面不应引入桌面表格,应使用 mobile-list-card 或移动卡片列表。",
|
||
evidence: match[0],
|
||
});
|
||
match = pattern.regex.exec(source);
|
||
}
|
||
}
|
||
}
|
||
|
||
function scanMobilePcShell(file, source) {
|
||
if (!isMobileSourceFile(file)) {
|
||
return;
|
||
}
|
||
|
||
const patterns = [
|
||
/AppShell|DesktopShell|Sidebar|NavLinks/g,
|
||
/备用模块|data-testid=["']sidebar["']|<aside\b/g,
|
||
];
|
||
|
||
for (const regex of patterns) {
|
||
let match = regex.exec(source);
|
||
while (match) {
|
||
addFinding({
|
||
rule: "mobile.no_pc_shell",
|
||
severity: "error",
|
||
file,
|
||
line: lineNumber(source, match.index),
|
||
message: "专用 /mobile 页面不应复用 PC 壳、侧栏或备用模块导航。",
|
||
evidence: match[0],
|
||
});
|
||
match = regex.exec(source);
|
||
}
|
||
}
|
||
}
|
||
|
||
function scanRawBusinessIdentifiers(file, source) {
|
||
const normalized = toPosix(file);
|
||
if (!/Reimbursement|Invoice|Leave|Purchase|Travel/.test(path.basename(normalized))) {
|
||
return;
|
||
}
|
||
|
||
const patterns = [
|
||
{
|
||
regex: /formNumber\s*(?:\|\||\?\?)\s*(?:item|detail|reimbursement)\.id/g,
|
||
message: "报销主展示不应在业务编号缺失时直接回退到 UUID。",
|
||
},
|
||
{
|
||
regex: /(?:报销|发票|请假|采购|差旅)ID/g,
|
||
message: "面向用户的主信息不应使用内部 ID,应使用业务编号或摘要。",
|
||
},
|
||
];
|
||
|
||
for (const pattern of patterns) {
|
||
let match = pattern.regex.exec(source);
|
||
while (match) {
|
||
addFinding({
|
||
rule: "data.no_uuid_primary_display",
|
||
severity: "warning",
|
||
file,
|
||
line: lineNumber(source, match.index),
|
||
message: pattern.message,
|
||
evidence: match[0],
|
||
});
|
||
match = pattern.regex.exec(source);
|
||
}
|
||
}
|
||
}
|
||
|
||
function scanUuidPrimaryDisplay(file, source) {
|
||
const patterns = [
|
||
{
|
||
regex: /(?:任务|实例|记录|队列|员工|部门|审批|发票|报销|请假|采购|差旅|资产)?\s*ID\s*[::][^\n]{0,100}\{[^}\n]*(?:\.id\b|\bid\b)[^}\n]*\}/g,
|
||
message: "面向用户主展示不应直接显示内部 UUID,应使用业务编号、标题或摘要。",
|
||
},
|
||
{
|
||
regex: /(?:编号|单号)\s*[::][^\n]{0,100}\{[^}\n]*\?\?[^}\n]*(?:\.id\b|\bid\b)[^}\n]*\}/g,
|
||
message: "业务编号缺失时不应直接回退展示内部 UUID,应显示“暂未生成”或业务摘要。",
|
||
},
|
||
{
|
||
regex: /\{[^}\n]*\.id\.slice\(\s*0\s*,\s*(?:6|8|10)\s*\)[^}\n]*\}/g,
|
||
message: "面向用户页面不应展示截断 UUID 作为主信息。",
|
||
},
|
||
];
|
||
|
||
for (const pattern of patterns) {
|
||
let match = pattern.regex.exec(source);
|
||
while (match) {
|
||
addFinding({
|
||
rule: "data.no_uuid_primary_display",
|
||
severity: "warning",
|
||
file,
|
||
line: lineNumber(source, match.index),
|
||
message: pattern.message,
|
||
evidence: match[0].replace(/\s+/g, " ").slice(0, 180),
|
||
});
|
||
match = pattern.regex.exec(source);
|
||
}
|
||
}
|
||
}
|
||
|
||
function scanReimbursementRawCodeDisplay(file, source) {
|
||
const normalized = toPosix(path.relative(rootDir, file));
|
||
if (normalized !== "client/src/pages/ReimbursementDetailPage.tsx") {
|
||
return;
|
||
}
|
||
|
||
const patterns = [
|
||
{
|
||
regex: /状态:\{detail\.status\}/g,
|
||
message: "PC 报销详情不应直接展示报销状态码,应显示中文业务状态。",
|
||
},
|
||
{
|
||
regex: /\{item\.status\}/g,
|
||
message: "PC 报销详情不应直接展示发票状态码,应显示中文业务状态。",
|
||
},
|
||
{
|
||
regex: /expenseType\?\.code/g,
|
||
message: "普通报销详情不应展示费用类型 code,应显示中文费用类型名。",
|
||
},
|
||
{
|
||
regex: /expenseType\?\.name\s*\?\?\s*item\.expenseTypeId/g,
|
||
message: "普通报销详情不应在费用类型缺失时回退展示内部费用类型 ID。",
|
||
},
|
||
{
|
||
regex: /expenseType\?\.name\s*\?\?\s*linkedItem\.expenseTypeId/g,
|
||
message: "普通报销详情的发票关联明细不应回退展示内部费用类型 ID。",
|
||
},
|
||
{
|
||
regex: /return\s+linkedItemId;/g,
|
||
message: "普通报销详情的发票关联明细不应回退展示内部明细 ID。",
|
||
},
|
||
{
|
||
regex: /invoiceId\.slice\(0,\s*8\)/g,
|
||
message: "普通报销详情的发票失败提示不应展示内部发票 ID 片段,应显示发票标题或用户可理解原因。",
|
||
},
|
||
];
|
||
|
||
for (const pattern of patterns) {
|
||
let match = pattern.regex.exec(source);
|
||
while (match) {
|
||
addFinding({
|
||
rule: "business.no_raw_reimbursement_codes",
|
||
severity: "error",
|
||
file,
|
||
line: lineNumber(source, match.index),
|
||
message: pattern.message,
|
||
evidence: match[0],
|
||
});
|
||
match = pattern.regex.exec(source);
|
||
}
|
||
}
|
||
}
|
||
|
||
function extractStringList(source, name) {
|
||
const assignment = new RegExp(`const\\s+${name}\\s*=\\s*\\[([\\s\\S]*?)\\];`, "m").exec(source);
|
||
if (!assignment) {
|
||
return [];
|
||
}
|
||
return Array.from(assignment[1].matchAll(/["']([^"']+)["']/g)).map((match) => 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,
|
||
});
|
||
}
|
||
}
|
||
|
||
function scanFileSource(file, source) {
|
||
scanForbiddenCopy(file, source);
|
||
scanUserFacingBusinessTerms(file, source);
|
||
scanMobileDarkControls(file, source);
|
||
scanNativeFileInputs(file, source);
|
||
scanVisibleEnglishUploadCopy(file, source);
|
||
scanMobileCapturePdfInputs(file, source);
|
||
scanMobileDesktopTables(file, source);
|
||
scanMobilePcShell(file, source);
|
||
scanUuidPrimaryDisplay(file, source);
|
||
scanRawBusinessIdentifiers(file, source);
|
||
scanReimbursementRawCodeDisplay(file, source);
|
||
scanNavigationTitleCoverage(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 <div><table><tbody /></table><aside>备用模块</aside><button>Upload</button><div>任务ID:{task.id}</div></div>;
|
||
}
|
||
`,
|
||
expectedRules: [
|
||
"mobile.no_desktop_table",
|
||
"mobile.no_pc_shell",
|
||
"copy.no_english_upload_labels",
|
||
"data.no_uuid_primary_display",
|
||
],
|
||
expectedSuppressedRules: [],
|
||
},
|
||
{
|
||
file: "client/src/pages/AuditLogsPage.tsx",
|
||
source: `
|
||
export function AuditLogs() {
|
||
return <div>记录ID:{item.id}</div>;
|
||
}
|
||
`,
|
||
expectedRules: [],
|
||
expectedSuppressedRules: ["data.no_uuid_primary_display"],
|
||
},
|
||
];
|
||
|
||
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;
|
||
}
|