文件
hyb-oa/scripts/qa/check-error-reason-contract.mjs
wangsli a837912638
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
feat: 完成错误原因合同与付款就绪提示(09-05-error-explainability-payment-readiness)
2026-09-05 19:00:35 +08:00

256 行
15 KiB
JavaScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const require = createRequire(import.meta.url);
const ts = require("../../client/node_modules/typescript");
const root = fileURLToPath(new URL("../../", import.meta.url));
const primaryFiles = new Set([
"server/src/routes/paymentBatches.js", "server/src/routes/reimbursements.js",
"server/src/routes/leave.js", "server/src/services/leave.js",
"server/src/routes/attendanceCorrections.js",
]);
const primaryInvoiceOperations = new Set(["/invoice-pool/:id/bind", "/invoice-pool/:id/unbind", "function:resolveInvoiceLinkedItem"]);
export function operationFor(node) {
for (let parent = node.parent; parent; parent = parent.parent) {
if (ts.isCallExpression(parent) && ts.isPropertyAccessExpression(parent.expression) && ["get", "post", "patch", "delete"].includes(parent.expression.name.text) && literal(parent.arguments[0])?.startsWith("/")) return literal(parent.arguments[0]);
if (ts.isFunctionDeclaration(parent)) return `function:${parent.name?.text ?? "anonymous"}`;
}
return null;
}
function isPrimary(entry) {
return primaryFiles.has(entry.file) || (entry.file === "server/src/routes/invoicePool.js" && primaryInvoiceOperations.has(entry.operation));
}
function legacyKey(entry) {
return `${entry.file}:${entry.operation ?? "module"}:${entry.fingerprint}`;
}
function parse(file, source) {
const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
if (tree.parseDiagnostics.length) throw new Error("source_parse_failed");
return tree;
}
function visit(node, callback) {
callback(node);
ts.forEachChild(node, (child) => visit(child, callback));
}
function shape(node) {
if (ts.isParenthesizedExpression(node)) return shape(node.expression);
const children = [];
ts.forEachChild(node, (child) => { children.push(shape(child)); });
return [node.kind, typeof node.text === "string" ? node.text : null, children];
}
export function fingerprint(node) {
return crypto.createHash("sha256").update(JSON.stringify(shape(node))).digest("hex");
}
function literal(node) {
return node && ts.isStringLiteralLike(node) ? node.text : null;
}
function properties(node) {
if (!node || !ts.isObjectLiteralExpression(node)) return {};
return Object.fromEntries(node.properties.filter(ts.isPropertyAssignment).map((property) => [property.name.text, literal(property.initializer)]));
}
function isSafeMessage(value) {
return typeof value === "string" && /[\u3400-\u9fff]/.test(value)
&& !/参数校验失败|https?:\/\/|\/api\/|(?:^|\s)\/[A-Za-z0-9._-]+|[A-Za-z]:\\|^\s*[\[{]|\d{12,}|\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i.test(value);
}
function declarations(source, name) {
let value;
visit(parse("contract.ts", source), (node) => {
if (ts.isVariableDeclaration(node) && node.name.text === name) {
if (value) throw new Error("duplicate_contract_declaration");
value = node.initializer;
}
});
if (!value) throw new Error("missing_contract_declaration");
return value;
}
export function inventory(sources) {
const legacy = [];
const used = [];
for (const [file, source] of Object.entries(sources)) {
const tree = parse(file, source);
const aliases = new Map();
visit(tree, (node) => {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isIdentifier(node.initializer)) aliases.set(node.name.text, node.initializer.text);
if (ts.isBindingElement(node) && node.propertyName && ts.isIdentifier(node.name)) aliases.set(node.name.text, node.propertyName.text);
if (ts.isImportSpecifier(node) && node.propertyName) aliases.set(node.name.text, node.propertyName.text);
});
visit(tree, (node) => {
if (!ts.isCallExpression(node) && !ts.isNewExpression(node)) return;
let name = ts.isIdentifier(node.expression) ? node.expression.text : node.expression.name?.text;
const visitedNames = new Set();
while (aliases.has(name) && !visitedNames.has(name)) { visitedNames.add(name); name = aliases.get(name); }
if (name === "buildBusinessValidationError" || name === "addPaymentReadinessBlocker") {
const argument = node.arguments?.[0];
const registryValidated = argument && ts.isPropertyAccessExpression(argument) && argument.name.text === "code"
&& ts.isCallExpression(argument.expression) && argument.expression.expression.getText() === "getErrorReason";
if (!registryValidated) used.push({ file, code: literal(argument) });
}
const errorProperties = properties(node.arguments?.[0]);
let containsValidationCode = false;
const codeExpression = node.arguments?.[0]?.properties?.find((property) => property.name?.text === "code")?.initializer;
if (codeExpression) visit(codeExpression, (child) => { if (literal(child) === "VALID_001") containsValidationCode = true; });
const appError = name === "AppError" && (containsValidationCode || errorProperties.message === "参数校验失败");
if (!/^build(?!Business).*ValidationError$/.test(name ?? "") && !appError) return;
const errorDetails = node.arguments?.[0]?.properties?.find((property) => property.name?.text === "details")?.initializer;
const reasonProperty = errorDetails?.properties?.find((property) => property.name?.text === "reasonCode");
if (file === "server/src/utils/businessValidationError.js" && appError && operationFor(node) === "function:buildBusinessValidationError"
&& reasonProperty?.initializer?.getText(tree) === "reason.code") return;
legacy.push({ file, fingerprint: fingerprint(node), operation: operationFor(node) });
});
}
return { legacy, used };
}
function sourceFiles(directory) {
return fs.readdirSync(path.join(root, directory), { withFileTypes: true }).flatMap((entry) => {
const file = `${directory}/${entry.name}`;
return entry.isDirectory() ? sourceFiles(file) : /\.[cm]?[jt]sx?$/.test(file) ? [file] : [];
});
}
export function repositoryInput() {
return {
registry: fs.readFileSync(path.join(root, "server/src/constants/errorReasons.js"), "utf8"),
mapping: fs.readFileSync(path.join(root, "client/src/api/errorReasons.ts"), "utf8"),
expectedMessages: JSON.parse(fs.readFileSync(path.join(root, "server/tests/unit/fixtures/error-reason-messages.json"), "utf8")),
sources: Object.fromEntries(sourceFiles("server/src").map((file) => [file, fs.readFileSync(path.join(root, file), "utf8")])),
baseline: JSON.parse(fs.readFileSync(path.join(root, "scripts/qa/error-reason-legacy-baseline.json"), "utf8")),
};
}
export function check(input) {
const findings = [];
const add = (kind, extra = {}) => findings.push({ kind, ...extra });
const registered = new Set();
const rows = [...declarations(input.registry, "ERROR_REASONS").elements].map(properties);
if (!rows.length) add("empty_registry");
for (const row of rows) {
if (registered.has(row.code)) add("duplicate_reason", { code: row.code });
registered.add(row.code);
if (input.expectedMessages?.[row.code] !== row.defaultMessage) add("missing_content_assertion", { code: row.code });
if (!/^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/.test(row.code ?? "") || row.domain !== row.code?.split(".")[0]
|| !["user", "internal"].includes(row.audience) || !row.recovery || !isSafeMessage(row.defaultMessage)) add("invalid_registry_metadata");
}
const mapped = new Set();
for (const entry of declarations(input.mapping, "ERROR_REASON_MESSAGES").properties) {
const code = entry.name?.text;
if (mapped.has(code)) add("duplicate_mapping", { code });
mapped.add(code);
if (!registered.has(code)) add("orphan_mapping", { code });
if (!isSafeMessage(literal(entry.initializer))) add("unsafe_mapping", { code });
}
for (const row of rows) if (row.audience === "user" && !mapped.has(row.code)) add("missing_mapping", { code: row.code });
for (const code of Object.keys(input.expectedMessages ?? {})) if (!registered.has(code)) add("orphan_content_assertion", { code });
const { legacy, used } = inventory(input.sources);
for (const use of used) if (!registered.has(use.code)) add("unregistered_reason", use);
if (input.baseline.version !== 1) add("invalid_baseline_version");
const remaining = new Map();
const today = new Date().toISOString().slice(0, 10);
for (const entry of input.baseline.entries ?? []) {
const key = legacyKey(entry);
if (isPrimary(entry)) add("primary_exception_forbidden", { file: entry.file });
const expiresAt = Date.parse(`${entry.expiresOn}T00:00:00Z`);
if (!entry.owner || !entry.reason || !/^\d{4}-\d{2}-\d{2}$/.test(entry.expiresOn ?? "") || !Number.isFinite(expiresAt)
|| new Date(expiresAt).toISOString().slice(0, 10) !== entry.expiresOn || !Number.isSafeInteger(entry.count) || entry.count < 1) add("invalid_exception", { file: entry.file });
if (entry.expiresOn <= today) add("expired_exception", { file: entry.file });
if (remaining.has(key)) add("duplicate_exception", { file: entry.file });
remaining.set(key, entry.count);
}
for (const entry of legacy) {
if (isPrimary(entry)) add("primary_legacy_forbidden", { file: entry.file });
const key = legacyKey(entry);
const count = remaining.get(key) ?? 0;
if (count < 1) add("new_legacy", { file: entry.file });
else remaining.set(key, count - 1);
}
for (const [key, count] of remaining) if (count > 0) add("stale_exception", { file: key.split(":")[0] });
const domains = {};
const domain = (name) => domains[name] ??= { registered: 0, migrated: 0, legacy: 0, expired: 0 };
for (const row of rows) domain(row.domain).registered += 1;
for (const use of used) domain(use.code?.split(".")[0] ?? "unresolved").migrated += 1;
for (const entry of legacy) domain(path.basename(entry.file, path.extname(entry.file))).legacy += 1;
for (const entry of input.baseline.entries ?? []) if (entry.expiresOn <= today) domain(path.basename(entry.file, path.extname(entry.file))).expired += entry.count;
return { pass: findings.length === 0, registered: rows.length, migrated: used.length, legacy: legacy.length, primaryExceptions: (input.baseline.entries ?? []).filter(isPrimary).length, domains, findings };
}
function selfTest() {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "error-reason-contract-"));
const registry = 'const ERROR_REASONS = [{code:"first.required",domain:"first",audience:"user",defaultMessage:"请补充资料。",recovery:"edit"},{code:"second.required",domain:"second",audience:"user",defaultMessage:"请确认审核人。",recovery:"edit"}];';
const mapping = 'const ERROR_REASON_MESSAGES = {"first.required":"请补充资料。","second.required":"请确认审核人。"};';
const legacySource = 'throw buildValidationError({status:"old"}, 409);';
const sourceFile = "server/src/routes/legacy.js";
const sample = inventory({ [sourceFile]: legacySource }).legacy[0];
const base = { registry, mapping, sources: { [sourceFile]: legacySource, "server/src/routes/example.js": 'buildBusinessValidationError("first.required", {status:"same"}); buildBusinessValidationError("second.required", {status:"same"});' }, baseline: { version: 1, entries: [{ ...sample, count: 1, owner: "task-owner", reason: "deferred domain", expiresOn: "2099-01-01" }] } };
base.expectedMessages = { "first.required": "请补充资料。", "second.required": "请确认审核人。" };
const cases = [
["valid-cross-domain", () => {}, true],
["unregistered", (input) => { input.sources[sourceFile] += 'buildBusinessValidationError("missing.required");'; }, false],
["missing-mapping", (input) => { input.mapping = mapping.replace('"second.required":"请确认审核人。"', ''); }, false],
["typo", (input) => { input.mapping = mapping.replace("second.required", "second.requird"); }, false],
["new-legacy", (input) => { input.sources[sourceFile] += legacySource; }, false],
["structural-change", (input) => { input.sources[sourceFile] = legacySource.replace('"old"', '"new"'); }, false],
["expired", (input) => { input.baseline.entries[0].expiresOn = "2000-01-01"; }, false],
["one-added-one-removed", (input) => { input.sources[sourceFile] = 'buildValidationError({status:"replacement"}, 409);'; }, false],
["formatting-stable", (input) => { input.sources[sourceFile] = "throw buildValidationError(\n { status: 'old', },\n 409,\n);"; }, true],
["primary-exception", (input) => { input.sources["server/src/routes/paymentBatches.js"] = legacySource; input.baseline.entries.push({ ...input.baseline.entries[0], file: "server/src/routes/paymentBatches.js" }); }, false],
["duplicate-registry", (input) => { input.registry = registry.replace('code:"second.required",domain:"second"', 'code:"first.required",domain:"first"'); }, false],
["dynamic-reason", (input) => { input.sources[sourceFile] += 'buildBusinessValidationError(variable);'; }, false],
["missing-content-assertion", (input) => { delete input.expectedMessages["first.required"]; }, false],
["aliased-legacy", (input) => { input.sources[sourceFile] += 'const reject = buildValidationError; reject({status:"new"});'; }, false],
["aliased-unregistered", (input) => { input.sources[sourceFile] += 'const reject = buildBusinessValidationError; reject("first.typo");'; }, false],
["invalid-expiry", (input) => { input.baseline.entries[0].expiresOn = "2099-02-31"; }, false],
["moved-operation", (input) => { input.sources[sourceFile] = `function moved() { ${legacySource} }`; }, false],
["conditional-validation-code", (input) => { input.sources[sourceFile] += 'new AppError({code: condition ? "VALID_001" : "OTHER", message:"参数校验失败"});'; }, false],
["unsafe-client-message", (input) => { input.mapping = mapping.replace("请补充资料。", "请补充资料 /api/private"); }, false],
["unsafe-registry-message", (input) => { input.registry = registry.replace("请补充资料。", "请补充资料 /api/private"); input.expectedMessages["first.required"] = "请补充资料 /api/private"; }, false],
];
try {
for (const [name, mutate, expectedPass] of cases) {
const input = structuredClone(base);
mutate(input);
const file = path.join(directory, "fixture.json");
fs.writeFileSync(file, JSON.stringify(input));
const result = spawnSync(process.execPath, [fileURLToPath(import.meta.url), "--fixture", file], { encoding: "utf8" });
assert.equal(result.status, expectedPass ? 0 : 1, name);
assert.equal(JSON.parse(result.stdout).pass, expectedPass, name);
console.log(`${name}: PASS (exit=${result.status})`);
}
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
if (process.argv.includes("--self-test")) selfTest();
else {
const fixtureIndex = process.argv.indexOf("--fixture");
const input = fixtureIndex >= 0 ? JSON.parse(fs.readFileSync(process.argv[fixtureIndex + 1], "utf8")) : repositoryInput();
const report = check(input);
console.log(JSON.stringify(report, null, 2));
process.exitCode = report.pass ? 0 : 1;
}
} catch {
console.log(JSON.stringify({ pass: false, findings: [{ kind: "contract_scan_failed" }] }));
process.exitCode = 1;
}
}