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; } }