#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, "../.."); const REQUIRED_COLUMNS = [ "ID", "Title", "Description", "Acceptance", "Test_Method", "Tools", "Dev_Status", "Review1_Status", "Regression_Status", "Files", "Dependencies", "Notes", ]; const VALID_STATUSES = new Set(["TODO", "DOING", "DONE"]); const VALID_PROFILES = new Set(["general", "wecom", "wecom-approval-fixture", "wecom-callback", "docs-only"]); const WAPC2F_ISSUE = "issues/2026-05-19_10-24-43-wecom-approval-fixture-auto-generate-v1.csv"; const WPROC2_ISSUE = "issues/2026-05-19_11-15-10-process-gate-automation-v1.csv"; function usage() { return `Usage: node scripts/qa/check-process-contract.mjs --baseline [--json] node scripts/qa/check-process-contract.mjs --issue --profile [--json] This QA-only process gate is read-only. It validates Issue CSV contracts and current WeCom process facts without printing secrets or raw external payloads.`; } export function parseArgs(argv = process.argv.slice(2)) { const options = { baseline: false, issue: "", profile: "general", json: false, help: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--baseline") { options.baseline = true; } else if (arg === "--issue") { options.issue = argv[index + 1] ?? ""; index += 1; } else if (arg.startsWith("--issue=")) { options.issue = arg.slice("--issue=".length); } else if (arg === "--profile") { options.profile = argv[index + 1] ?? ""; index += 1; } else if (arg.startsWith("--profile=")) { options.profile = arg.slice("--profile=".length); } else if (arg === "--json") { options.json = true; } else if (arg === "--help" || arg === "-h") { options.help = true; } else { throw new Error(`unknown argument: ${arg}`); } } if (!options.help && !options.baseline && !options.issue) { throw new Error("either --baseline or --issue is required"); } if (options.baseline && options.issue) { throw new Error("--baseline and --issue cannot be used together"); } if (!VALID_PROFILES.has(options.profile)) { throw new Error(`unsupported profile: ${options.profile}`); } return options; } function createResult(scope, profile = "general") { return { status: "PASS", scope, profile, checks: [], errors: [], warnings: [], }; } function addCheck(result, name, status, detail = "") { result.checks.push({ name, status, detail }); } function addError(result, code, message) { result.errors.push({ code, message }); result.status = "FAIL"; } function addWarning(result, code, message) { result.warnings.push({ code, message }); } function splitCsvLine(line) { const cells = []; let current = ""; let quoted = false; for (let index = 0; index < line.length; index += 1) { const char = line[index]; const next = line[index + 1]; if (quoted && char === "\"" && next === "\"") { current += "\""; index += 1; continue; } if (char === "\"") { quoted = !quoted; continue; } if (!quoted && char === ",") { cells.push(current); current = ""; continue; } current += char; } cells.push(current); return cells; } export function parseIssueCsv(source) { const lines = source.split(/\r?\n/).filter((line) => line.trim()); if (lines.length === 0) { return { header: [], rows: [] }; } const header = splitCsvLine(lines[0]).map((cell) => cell.trim()); const rows = lines.slice(1).map((line, lineIndex) => { const cells = splitCsvLine(line); const row = Object.fromEntries(header.map((key, index) => [key, cells[index] ?? ""])); row.__line = lineIndex + 2; return row; }); return { header, rows }; } function includesAllColumns(header) { return REQUIRED_COLUMNS.every((column) => header.includes(column)); } function textOfRows(rows) { return rows.map((row) => REQUIRED_COLUMNS.map((column) => row[column] ?? "").join(" ")).join("\n"); } function normalize(value) { return String(value ?? "").toLowerCase(); } function hasTerm(text, term) { return normalize(text).includes(normalize(term)); } function hasAny(text, terms) { return terms.some((term) => hasTerm(text, term)); } function hasAll(text, terms) { return terms.every((term) => hasTerm(text, term)); } function validateBasicCsv({ result, header, rows }) { if (!includesAllColumns(header)) { const missing = REQUIRED_COLUMNS.filter((column) => !header.includes(column)); addError(result, "csv.required_columns", `missing required columns: ${missing.join(", ")}`); } else { addCheck(result, "csv.required_columns", "PASS", "all required columns exist"); } if (rows.length === 0) { addError(result, "csv.rows", "Issue CSV has no rows"); return; } addCheck(result, "csv.rows", "PASS", `${rows.length} rows`); const ids = new Set(); for (const row of rows) { const rowId = row.ID || `line ${row.__line}`; if (!row.ID) { addError(result, "csv.id", `missing ID at line ${row.__line}`); } else if (ids.has(row.ID)) { addError(result, "csv.id", `duplicate ID: ${row.ID}`); } else { ids.add(row.ID); } for (const statusField of ["Dev_Status", "Review1_Status", "Regression_Status"]) { if (!VALID_STATUSES.has(row[statusField])) { addError(result, "csv.status", `${rowId} has invalid ${statusField}: ${row[statusField] || ""}`); } } try { const deps = JSON.parse(row.Dependencies || ""); if (!Array.isArray(deps) || deps.some((item) => typeof item !== "string")) { addError(result, "csv.dependencies", `${rowId} Dependencies must be a JSON string array`); } else { for (const dep of deps) { if (!ids.has(dep) && !rows.some((candidate) => candidate.ID === dep)) { addError(result, "csv.dependencies", `${rowId} depends on unknown row: ${dep}`); } } } } catch (error) { addError(result, "csv.dependencies", `${rowId} Dependencies is not valid JSON array`); } const testMethod = row.Test_Method || ""; if (!hasAll(testMethod, ["受影响流程", "验证步骤"])) { addError(result, "csv.test_method", `${rowId} Test_Method must include affected business flow and reproducible steps`); } if (!row.Files || !row.Files.trim()) { addError(result, "csv.files", `${rowId} Files must not be empty`); } if (!row.Notes || !row.Notes.trim()) { addError(result, "csv.notes", `${rowId} Notes must not be empty`); } const rowText = REQUIRED_COLUMNS.map((column) => row[column] ?? "").join(" "); const hasRiskyWriteMarker = /(--live|WECOM_APPROVAL_FIXTURE_WRITE|live 写入|真实写|写企微|企微后台|applyevent|create_template|submit-test-approvals|部署|生产)/i.test(rowText); if (hasRiskyWriteMarker && !hasAny(rowText, ["回滚", "清理", "隐藏", "停用", "保留审计", "不部署", "不写", "不得修改正式模板"])) { addError(result, "csv.rollback", `${rowId} mentions live/write/public risk but lacks rollback or cleanup wording`); } } if (!result.errors.some((item) => item.code.startsWith("csv."))) { addCheck(result, "csv.format", "PASS", "IDs, statuses, dependencies, Test_Method, Files and Notes are valid"); } } function validateDocsOnly({ result, rows }) { const text = textOfRows(rows); if (hasAny(text, ["不改业务代码", "docs-only", "contract-only", "文档"])) { addCheck(result, "profile.docs_only_scope", "PASS", "docs-only or contract-only boundary declared"); } else { addWarning(result, "profile.docs_only_scope", "docs-only profile did not find explicit docs-only wording"); } } function validateWecom({ result, rows }) { const text = textOfRows(rows); if (!hasAny(text, ["WECOM_", "Secret", "企微", "企业微信"])) { addError(result, "wecom.scope", "WeCom profile must mention WeCom scope and Secret/API source"); } if (!hasAny(text, ["固定 WECOM", "固定 `WECOM", "Secret 来源", "Secret 矩阵"])) { addError(result, "wecom.secret_source", "WeCom contract must fix its Secret source"); } if (!hasAny(text, ["不尝试其他 Secret", "无 fallback", "不得靠多个 Secret", "不允许运行时猜 Secret", "不再用运行时 Secret fallback"])) { addError(result, "wecom.secret_source", "WeCom contract must fix Secret source and disallow runtime Secret guessing"); } if (!hasAny(text, ["NEEDS_CONFIG", "SKIP/BLOCKED_BY_EXTERNAL_CONFIG", "BLOCKED_BY_EXTERNAL_CONFIG"])) { addError(result, "wecom.external_config", "WeCom contract must classify missing config or permission as NEEDS_CONFIG or SKIP/BLOCKED_BY_EXTERNAL_CONFIG"); } if (!hasAny(text, ["只读", "dry-run", "默认 dry-run", "不写"])) { addError(result, "wecom.read_or_dry_run", "WeCom contract must default to read-only or dry-run"); } if (!result.errors.some((item) => item.code.startsWith("wecom."))) { addCheck(result, "profile.wecom", "PASS", "Secret, external config, and read/dry-run gates are declared"); } } function validateWecomApprovalFixture({ result, rows }) { validateWecom({ result, rows }); const text = textOfRows(rows); const requiredTerms = [ "WAPC2F", "OA-POC", "WECOM_AGENT_SECRET", "WECOM_APPROVAL_FIXTURE_WRITE=1", "--live", "--create-templates", "--submit-test-approvals", "dry-run", "SMOKE002", "SMOKE101", "自审", "BLOCKED_BY_EXTERNAL_CONFIG", "NEEDS_CONFIG", ]; for (const term of requiredTerms) { if (!hasTerm(text, term)) { addError(result, "fixture.required_term", `WAPC2F contract must mention ${term}`); } } if (hasAny(text, ["默认 wangshunli", "申请人 wangshunli", "审批人 wangshunli", "wangshunli -> wangshunli"])) { addError(result, "fixture.self_approval", "WAPC2F must not default to wangshunli self-approval"); } if (!hasAny(text, ["不是同一 OA 员工", "不是同一企微 userid", "禁止自审", "自审硬阻断"])) { addError(result, "fixture.non_self_approval", "WAPC2F must explicitly block self-approval by OA employee and WeCom userid"); } const routePattern = /WAPC2F[\s\S]{0,240}WCBK1[\s\S]{0,240}WACP3[\s\S]{0,240}WADM1/; if (!routePattern.test(text)) { addError(result, "fixture.route_order", "WAPC2F route must show WAPC2F before WCBK1, WACP3 and WADM1"); } const privacyTerms = [ "手机号", "邮箱", "token", "secret", "完整模板 ID", "完整 sp_no", "完整 userid", "原始 payload", ]; for (const term of privacyTerms) { if (!hasTerm(text, term)) { addError(result, "fixture.privacy", `WAPC2F contract must declare no ${term} in reports`); } } if (!result.errors.some((item) => item.code.startsWith("fixture."))) { addCheck(result, "profile.wecom_approval_fixture", "PASS", "WAPC2F non-self, write switch, route and privacy gates are declared"); } } function validateWecomCallback({ result, rows }) { validateWecom({ result, rows }); const text = textOfRows(rows); const requiredTerms = [ "WCBK1", "GET /api/wecom/approval/callback", "POST /api/wecom/approval/callback", "WECOM_APPROVAL_CALLBACK_URL", "WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY", "msg_signature", "EncodingAESKey", "验签", "解密", "幂等", "只读", "NEEDS_CONFIG", "SKIP/BLOCKED_BY_EXTERNAL_CONFIG", "WACP3", "WADM1", ]; for (const term of requiredTerms) { if (!hasTerm(text, term)) { addError(result, "callback.required_term", `WCBK1 contract must mention ${term}`); } } if (!hasAny(text, ["不使用通讯录 Secret", "不尝试其他 Secret", "不允许运行时猜 Secret", "无 fallback"])) { addError(result, "callback.secret_source", "WCBK1 must disallow Secret fallback and runtime guessing"); } if (!hasAny(text, ["不写 OA 业务镜像", "不落业务镜像表", "不写 OA/企微业务数据", "不写企微数据"])) { addError(result, "callback.read_only", "WCBK1 must declare no OA/WeCom business writes"); } const privacyTerms = [ "原始 XML", "密文", "完整 sp_no", "完整 userid", "完整 template_id", "Token", "EncodingAESKey", "原始 payload", ]; for (const term of privacyTerms) { if (!hasTerm(text, term)) { addError(result, "callback.privacy", `WCBK1 contract must declare no ${term} in reports`); } } const routePattern = /WCBK1[\s\S]{0,260}WACP3[\s\S]{0,260}WADM1/; if (!routePattern.test(text)) { addError(result, "callback.route_order", "WCBK1 route must show WACP3 and WADM1 before mirror implementation"); } if (!result.errors.some((item) => item.code.startsWith("callback."))) { addCheck(result, "profile.wecom_callback", "PASS", "WCBK1 callback config, crypto, read-only, route and privacy gates are declared"); } } export function evaluateIssueContract({ csvText, issuePath = "", profile = "general" }) { const result = createResult(issuePath || "inline issue", profile); const { header, rows } = parseIssueCsv(csvText); validateBasicCsv({ result, header, rows }); if (profile === "docs-only") { validateDocsOnly({ result, rows }); } else if (profile === "wecom") { validateWecom({ result, rows }); } else if (profile === "wecom-approval-fixture") { validateWecomApprovalFixture({ result, rows }); } else if (profile === "wecom-callback") { validateWecomCallback({ result, rows }); } return result; } function readRepoText(relativePath, root = repoRoot) { return fs.readFileSync(path.join(root, relativePath), "utf8"); } function pathExists(relativePath, root = repoRoot) { return fs.existsSync(path.join(root, relativePath)); } function mergeChild(parent, child, label) { parent.checks.push({ name: label, status: child.status, detail: `${child.errors.length} errors, ${child.warnings.length} warnings`, }); for (const error of child.errors) { addError(parent, `${label}.${error.code}`, error.message); } for (const warning of child.warnings) { addWarning(parent, `${label}.${warning.code}`, warning.message); } } export function evaluateBaseline({ root = repoRoot } = {}) { const result = createResult("baseline", "baseline"); const requiredPaths = [ "docs/开发交付与变更管理规约.md", "docs/测试规约与回归门禁.md", "docs/企微优先开发路线图.md", "docs/企微API能力目录.md", "docs/文档索引.md", "docs/文档事实源状态矩阵.md", ".agents/skills/hyb-oa-delivery/SKILL.md", WAPC2F_ISSUE, ]; for (const relativePath of requiredPaths) { if (!pathExists(relativePath, root)) { addError(result, "baseline.required_path", `missing required path: ${relativePath}`); } } if (!result.errors.some((item) => item.code === "baseline.required_path")) { addCheck(result, "baseline.required_paths", "PASS", `${requiredPaths.length} required paths exist`); } const factPaths = requiredPaths.filter((item) => item !== WAPC2F_ISSUE); const factText = factPaths .filter((relativePath) => pathExists(relativePath, root)) .map((relativePath) => readRepoText(relativePath, root)) .join("\n"); for (const term of ["WPROC2", "check-process-contract", "process gate", "Definition of Ready", "Definition of Done", "WAPC2F"]) { if (!hasTerm(factText, term)) { addError(result, "baseline.fact_source", `core fact sources must mention ${term}`); } } if (!result.errors.some((item) => item.code === "baseline.fact_source")) { addCheck(result, "baseline.fact_sources", "PASS", "WPROC2 process gate terms are in core fact sources"); } if (pathExists(WAPC2F_ISSUE, root)) { const wapc2f = evaluateIssueContract({ csvText: readRepoText(WAPC2F_ISSUE, root), issuePath: WAPC2F_ISSUE, profile: "wecom-approval-fixture", }); mergeChild(result, wapc2f, "wapc2f"); } if (pathExists(WPROC2_ISSUE, root)) { const wproc2 = evaluateIssueContract({ csvText: readRepoText(WPROC2_ISSUE, root), issuePath: WPROC2_ISSUE, profile: "general", }); mergeChild(result, wproc2, "wproc2"); } return result; } function formatTextResult(result) { const lines = [`process-contract: status=${result.status} scope=${result.scope} profile=${result.profile}`]; for (const check of result.checks) { lines.push(`- ${check.status} ${check.name}${check.detail ? `: ${check.detail}` : ""}`); } for (const warning of result.warnings) { lines.push(`warning ${warning.code}: ${warning.message}`); } for (const error of result.errors) { lines.push(`error ${error.code}: ${error.message}`); } return lines.join("\n"); } async function main() { let options; try { options = parseArgs(); } catch (error) { console.error(error.message); console.error(usage()); process.exit(2); } if (options.help) { console.log(usage()); return; } const result = options.baseline ? evaluateBaseline() : evaluateIssueContract({ csvText: readRepoText(options.issue), issuePath: options.issue, profile: options.profile, }); if (options.json) { console.log(JSON.stringify(result, null, 2)); } else { console.log(formatTextResult(result)); } if (result.status !== "PASS") { process.exit(1); } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch((error) => { console.error(error?.stack || error?.message || String(error)); process.exit(1); }); }