WPROC2 增加开发流程自动化门禁
这个提交包含在:
@@ -0,0 +1,480 @@
|
||||
#!/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);
|
||||
});
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户