文件
hyb-oa/scripts/qa/check-process-contract.mjs
T

481 行
16 KiB
JavaScript

#!/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", "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 <csv> --profile <general|wecom|wecom-approval-fixture|docs-only> [--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 <csv> 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] || "<empty>"}`);
}
}
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");
}
}
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 });
}
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);
});
}