文件
hyb-oa/scripts/qa/check-ui-design-rules.mjs

796 行
26 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/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;
}