544 行
18 KiB
JavaScript
544 行
18 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", "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 <csv> --profile <general|wecom|wecom-approval-fixture|wecom-callback|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");
|
|
}
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|