QG6 完成QA治理矩阵与报告规范
这个提交包含在:
@@ -6,6 +6,8 @@ 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",
|
||||
@@ -25,6 +27,14 @@ const userFacingBusinessTermFiles = new Set([
|
||||
]);
|
||||
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("/");
|
||||
@@ -59,15 +69,29 @@ 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 }) {
|
||||
findings.push({
|
||||
const finding = {
|
||||
rule,
|
||||
severity,
|
||||
file: toPosix(path.relative(rootDir, file)),
|
||||
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) {
|
||||
@@ -130,6 +154,34 @@ function scanNativeFileInputs(file, 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)) {
|
||||
@@ -176,6 +228,70 @@ function scanUserFacingBusinessTerms(file, source) {
|
||||
}
|
||||
}
|
||||
|
||||
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))) {
|
||||
@@ -209,6 +325,38 @@ function scanRawBusinessIdentifiers(file, 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") {
|
||||
@@ -300,18 +448,82 @@ function scanNavigationTitleCoverage(file, source) {
|
||||
}
|
||||
}
|
||||
|
||||
function scanFile(file) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
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);
|
||||
@@ -322,18 +534,27 @@ const summary = {
|
||||
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 }, null, 2));
|
||||
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)`);
|
||||
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");
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户