796 行
26 KiB
JavaScript
796 行
26 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 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 = /<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 scanMobileDesktopContainers(file, source) {
|
||
if (!isMobileSourceFile(file)) {
|
||
return;
|
||
}
|
||
|
||
const patterns = [
|
||
{
|
||
regex: /\bcard-std\b[^"'`]{0,80}\bp-[45]\b/g,
|
||
evidence: "card-std p-4/p-5",
|
||
},
|
||
{
|
||
regex: /\bcard-std\b[^"'`]{0,80}\bp-6\b/g,
|
||
evidence: "card-std p-6",
|
||
},
|
||
];
|
||
|
||
for (const pattern of patterns) {
|
||
let match = pattern.regex.exec(source);
|
||
while (match) {
|
||
addFinding({
|
||
rule: "mobile.no_desktop_card_container",
|
||
severity: "error",
|
||
file,
|
||
line: lineNumber(source, match.index),
|
||
message: "专用 /mobile 页面不应把桌面 card-std 大容器作为主内容,应使用 mobile-card、mobile-list-card 或移动表单分组。",
|
||
evidence: match[0],
|
||
});
|
||
match = pattern.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 scanDuplicateBusinessIdentifierDisplay(file, source) {
|
||
const normalized = normalizeFindingFile(file);
|
||
if (!/^client\/src\/(?:pages|components)\//.test(normalized)) {
|
||
return;
|
||
}
|
||
|
||
const patterns = [
|
||
{
|
||
regex: /业务单号[::][\s\S]{0,160}\{task\.reference\}/g,
|
||
message: "审批卡片标题已有业务对象时,不应在同一卡片再次展示业务单号。",
|
||
},
|
||
{
|
||
regex: /业务:\$\{entityLabel\([^}]+\)\}\s*·\s*\$\{reference/g,
|
||
message: "审批卡片副标题不应重复拼接业务类型和业务编号,应使用固定主标题槽位。",
|
||
},
|
||
{
|
||
regex: /业务单号[::][\s\S]{0,220}(?:报销单号|采购单号|差旅单号)[::]/g,
|
||
message: "同一详情摘要不应同时展示通用业务单号和具体业务单号。",
|
||
},
|
||
];
|
||
|
||
for (const pattern of patterns) {
|
||
let match = pattern.regex.exec(source);
|
||
while (match) {
|
||
addFinding({
|
||
rule: "display.no_duplicate_business_identifier",
|
||
severity: "error",
|
||
file,
|
||
line: lineNumber(source, match.index),
|
||
message: pattern.message,
|
||
evidence: match[0].replace(/\s+/g, " ").slice(0, 180),
|
||
});
|
||
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,
|
||
});
|
||
}
|
||
}
|
||
|
||
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 <div><table><tbody /></table><aside>备用模块</aside><button>Upload</button><div>任务ID:{task.id}</div><div>业务单号:{task.reference}</div><div>参考飞书布局照搬</div></div>;
|
||
}
|
||
`,
|
||
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 <main className="card-std space-y-4 p-6">内容</main>;
|
||
}
|
||
`,
|
||
expectedRules: ["mobile.no_desktop_card_container"],
|
||
expectedSuppressedRules: [],
|
||
},
|
||
{
|
||
file: "client/src/pages/AuditLogsPage.tsx",
|
||
source: `
|
||
export function AuditLogs() {
|
||
return <div>记录ID:{item.id}</div>;
|
||
}
|
||
`,
|
||
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 ? <div>{query.data}</div> : 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 ? <><ReimbursementItemEvidenceList evidence={reimbursementEvidence} />提交时检查</> : 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;
|
||
}
|