文件
hl-api-changelog/scripts/validate-changelog-frontmatter.mjs
T
lc 3d6f6245a0
changelog-filename-gate / validate (pull_request) Successful in 2s
补齐行政区划三级联动接口文档与模板门禁(#6422)
2026-08-26 16:23:19 +08:00

474 行
17 KiB
JavaScript

#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
controlledRootForPath,
diffFromOptions,
parseArguments,
parseNameStatusZ,
} from './validate-changelog-filenames.mjs';
const FRONTEND_STATUSES = new Set([
'not_required',
'pending',
'claimed',
'implemented',
'released',
'verified',
]);
const BACKEND_STATUSES = new Set(['pending', 'tested', 'deployed', 'not_required']);
const GATEWAY_STATUSES = new Set(['pending', 'verified', 'not_required']);
const CONSUMERS = new Set(['admin', 'mp', 'internal', 'multiple']);
const CHANGE_TYPES = new Set(['新增接口', '修改接口', '删除接口', '修复', '前端缺陷', '前端优化', '前端修复']);
// 接口契约类条目:后端必须已部署测试服并实测(E_BACKEND_PENDING 硬门禁)才允许发布
const API_CHANGE_TYPES = new Set(['新增接口', '修改接口', '删除接口']);
const REQUIRED_KEYS = [
'schema',
'ticket',
'title',
'consumer',
'author',
'change_type',
'backend_status',
'gateway_status',
'frontend_status',
'frontend_owner',
'frontend_ref',
'target_release',
'verified_at',
'status_note',
'updated_at',
'base',
];
const HTTP_METHOD_PATTERN = '(?:GET|POST|PUT|PATCH|DELETE)';
const API_TEMPLATE_SECTION_PREFIXES = [
'二、变更接口清单',
'三、接口详情',
'四、契约约束与正确调用方式',
'六、边界行为',
'七、不影响范围',
'八、测试环境已验证',
'十、相关文档',
'关联 / 联系人',
];
const API_DETAIL_SUBSECTIONS = [
'使用场景',
'入参',
'出参',
'请求示例',
'响应示例',
'空数据 / 降级响应',
'错误响应',
'业务边界',
];
function ruleError(code, file, message) {
return { code, path: file, message };
}
function isIsoDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? '');
if (!match) {
return false;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const parsed = new Date(Date.UTC(year, month - 1, day));
return parsed.getUTCFullYear() === year
&& parsed.getUTCMonth() === month - 1
&& parsed.getUTCDate() === day;
}
function isIsoDateOrTime(value) {
if (isIsoDate(value)) {
return true;
}
return /^\d{4}-\d{2}-\d{2}T/.test(value ?? '')
&& Number.isFinite(Date.parse(value));
}
function sectionByPrefix(body, prefix, level = 2) {
const marker = `${'#'.repeat(level)} ${prefix}`;
const lines = String(body ?? '').replaceAll('\r\n', '\n').split('\n');
const start = lines.findIndex((line) => line.trim().startsWith(marker));
if (start < 0) {
return undefined;
}
const nextMarker = '#'.repeat(level);
let end = lines.length;
for (let index = start + 1; index < lines.length; index += 1) {
const value = lines[index].trim();
if (value.startsWith(`${nextMarker} `) && !value.startsWith(`${nextMarker}#`)) {
end = index;
break;
}
}
return lines.slice(start + 1, end).join('\n');
}
function apiEndpointKey(method, endpointPath) {
return `${method.trim().toUpperCase()} ${endpointPath.trim()}`;
}
function validateApiTemplate(file, metadata, body) {
const errors = [];
const missingSections = API_TEMPLATE_SECTION_PREFIXES.filter(
(prefix) => sectionByPrefix(body, prefix) === undefined,
);
if (missingSections.length > 0) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
`接口类正文必须仿照 CHANGELOG_TEMPLATE.md,缺少章节: ${missingSections.join('、')}`,
));
}
const listSection = sectionByPrefix(body, '二、变更接口清单');
const detailSection = sectionByPrefix(body, '三、接口详情');
if (listSection === undefined || detailSection === undefined) {
return errors;
}
const requiredHeader = /^\|\s*#\s*\|\s*接口\s*\|\s*方法\s*\|\s*路径\s*\|\s*变更类型\s*\|\s*说明\s*\|\s*$/m;
if (!requiredHeader.test(listSection)) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
'“二、变更接口清单”必须使用模板列: #、接口、方法、路径、变更类型、说明',
));
}
const listPattern = new RegExp(
'^\\|\\s*\\d+\\s*\\|\\s*[^|]+\\|\\s*('
+ HTTP_METHOD_PATTERN
+ ')\\s*\\|\\s*`([^`]+)`\\s*\\|\\s*[^|]+\\|\\s*[^|]+\\|\\s*$',
'gm',
);
const listed = [...listSection.matchAll(listPattern)]
.map((match) => apiEndpointKey(match[1], match[2]));
if (listed.length === 0) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
'“二、变更接口清单”至少需要一条带 METHOD 和反引号路径的接口记录',
));
}
const detailPattern = new RegExp(
'^###\\s+\\d+\\.\\s+.+?\\s+`('
+ HTTP_METHOD_PATTERN
+ ')\\s+([^`]+)`\\s*$',
'gm',
);
const detailMatches = [...detailSection.matchAll(detailPattern)];
const detailed = detailMatches.map((match) => apiEndpointKey(match[1], match[2]));
if (detailMatches.length === 0) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
'“三、接口详情”至少需要一个“### N. 接口名 `METHOD /path`”子节',
));
}
const listedSet = [...new Set(listed)].sort();
const detailedSet = [...new Set(detailed)].sort();
if (
listed.length !== listedSet.length
|| detailed.length !== detailedSet.length
|| listedSet.join('\n') !== detailedSet.join('\n')
) {
errors.push(ruleError(
'E_API_ENDPOINTS',
file,
'接口清单与逐接口详情的 METHOD/path 必须去重且一一对应',
));
}
for (let index = 0; index < detailMatches.length; index += 1) {
const match = detailMatches[index];
const next = detailMatches[index + 1];
const block = detailSection.slice(
match.index + match[0].length,
next ? next.index : detailSection.length,
);
const missing = API_DETAIL_SUBSECTIONS.filter(
(prefix) => sectionByPrefix(block, prefix, 4) === undefined,
);
const usage = sectionByPrefix(block, '使用场景', 4) ?? '';
const input = sectionByPrefix(block, '入参', 4) ?? '';
const output = sectionByPrefix(block, '出参', 4) ?? '';
const requestExample = sectionByPrefix(block, '请求示例', 4) ?? '';
const responseExample = sectionByPrefix(block, '响应示例', 4) ?? '';
const emptyResponse = sectionByPrefix(block, '空数据 / 降级响应', 4) ?? '';
const errorExample = sectionByPrefix(block, '错误响应', 4) ?? '';
const boundary = sectionByPrefix(block, '业务边界', 4) ?? '';
if (!/^\*\*VO\*\*:\s*`[^`]+`/m.test(block)) {
missing.push('VO 契约');
}
if (!usage.trim()) {
missing.push('使用场景说明');
}
if (!/^\|\s*字段\s*\|\s*位置\s*\|\s*类型\s*\|\s*必填\s*\|\s*约束\s*\|\s*说明\s*\|\s*$/m.test(input)) {
missing.push('入参字段表');
}
if (!/^\|\s*字段\s*\|\s*类型\s*\|\s*说明\s*\|\s*$/m.test(output)) {
missing.push('出参字段表');
}
if (!/```(?:json|http)\s*\n[\s\S]+?\n```/.test(requestExample)) {
missing.push('请求示例代码块');
}
if (!/```json\s*\n[\s\S]+?\n```/.test(responseExample)) {
missing.push('响应示例 JSON');
}
if (!emptyResponse.trim()) {
missing.push('空数据 / 降级说明');
}
if (!/```json\s*\n[\s\S]+?\n```/.test(errorExample)) {
missing.push('错误响应 JSON');
}
if (!/^\s*[-*]\s+\S/m.test(boundary)) {
missing.push('业务边界条目');
}
if (missing.length > 0) {
errors.push(ruleError(
'E_API_DETAIL',
file,
`${detailed[index]} 缺少逐接口自包含内容: ${[...new Set(missing)].join('、')}`,
));
}
}
const writeMethods = listed.some((entry) => /^(?:POST|PUT|PATCH|DELETE) /.test(entry));
if (writeMethods && sectionByPrefix(body, '五、数据库行为') === undefined) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
'包含写接口时必须按模板提供“五、数据库行为”章节(只写外部可观察行为,不泄露表结构)',
));
}
if (
['修改接口', '删除接口'].includes(metadata.change_type)
&& (
sectionByPrefix(body, '六.6、修改前后对比') === undefined
|| sectionByPrefix(body, '六.7、影响评估') === undefined
)
) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
'修改/删除接口必须提供“六.6、修改前后对比”和“六.7、影响评估”章节',
));
}
return errors;
}
export function parseFrontmatter(text) {
const value = String(text ?? '').replaceAll('\r\n', '\n');
if (!value.startsWith('---\n')) {
return { metadata: undefined, body: value };
}
const end = value.indexOf('\n---\n', 4);
if (end < 0) {
return { metadata: undefined, body: value };
}
const metadata = {};
for (const line of value.slice(4, end).split('\n')) {
const separator = line.indexOf(':');
if (separator < 0) {
continue;
}
const key = line.slice(0, separator).trim();
let fieldValue = line.slice(separator + 1).trim();
if (
(fieldValue.startsWith('"') && fieldValue.endsWith('"'))
|| (fieldValue.startsWith("'") && fieldValue.endsWith("'"))
) {
fieldValue = fieldValue.slice(1, -1);
}
metadata[key] = fieldValue;
}
return { metadata, body: value.slice(end + 5) };
}
export function validateFrontendState(metadata) {
const errors = [];
const status = metadata.frontend_status;
const owner = metadata.frontend_owner?.trim() ?? '';
const reference = metadata.frontend_ref?.trim() ?? '';
const release = metadata.target_release?.trim() ?? '';
const verifiedAt = metadata.verified_at?.trim() ?? '';
if (!FRONTEND_STATUSES.has(status)) {
return [`frontend_status 非法: ${status || '(空)'}`];
}
if (['claimed', 'implemented', 'released', 'verified'].includes(status) && !owner) {
errors.push(`${status} 必须填写 frontend_owner`);
}
if (['implemented', 'released', 'verified'].includes(status) && !reference) {
errors.push(`${status} 必须填写 frontend_ref`);
}
if (['released', 'verified'].includes(status) && !release) {
errors.push(`${status} 必须填写 target_release`);
}
if (status === 'verified' && !verifiedAt) {
errors.push('verified 必须填写 verified_at');
}
if (verifiedAt && !isIsoDateOrTime(verifiedAt)) {
errors.push('verified_at 必须是 ISO 日期或时间');
}
if (status === 'not_required' && [owner, reference, release, verifiedAt].some(Boolean)) {
errors.push('not_required 不得保留前端负责人、引用、版本或验证时间');
}
return errors;
}
export function validateFrontendTransition(current, target, reason = '') {
if (!FRONTEND_STATUSES.has(current) || !FRONTEND_STATUSES.has(target)) {
return ['frontend_status 非法'];
}
if (current === target) {
return [];
}
if (current === 'not_required' || target === 'not_required') {
return reason.trim() ? [] : ['涉及 not_required 的迁移必须填写原因'];
}
const order = ['pending', 'claimed', 'implemented', 'released', 'verified'];
const currentIndex = order.indexOf(current);
const targetIndex = order.indexOf(target);
if (targetIndex === currentIndex + 1) {
return [];
}
if (targetIndex < currentIndex) {
return reason.trim() ? [] : ['状态回退必须填写原因'];
}
return [`禁止跨级迁移: ${current} -> ${target}`];
}
export function validateV2Document(file, text, { requireV2 = false } = {}) {
const { metadata, body } = parseFrontmatter(text);
if (!metadata) {
return requireV2 ? [ruleError('E_FRONTMATTER', file, '新增 changelog 缺少 YAML Front Matter')] : [];
}
if (metadata.schema !== 'hl-changelog/v2') {
return requireV2
? [ruleError('E_SCHEMA', file, `新增 changelog 必须使用 hl-changelog/v2,当前为 ${metadata.schema || '(空)'}`)]
: [];
}
const errors = [];
for (const key of REQUIRED_KEYS) {
if (!(key in metadata)) {
errors.push(ruleError('E_REQUIRED', file, `frontmatter 缺少 ${key}`));
}
}
for (const key of ['ticket', 'title', 'consumer', 'author', 'change_type', 'backend_status', 'gateway_status', 'frontend_status', 'updated_at', 'base']) {
if (!metadata[key]?.trim()) {
errors.push(ruleError('E_REQUIRED', file, `${key} 不能为空`));
}
}
if (metadata.author && !/^\S+\(GIT\)$/.test(metadata.author)) {
errors.push(ruleError('E_AUTHOR', file, 'author 必须使用“登录名(GIT)”格式'));
}
if (!CHANGE_TYPES.has(metadata.change_type)) {
errors.push(ruleError('E_CHANGE_TYPE', file, `change_type 非法: ${metadata.change_type || '(空)'}`));
}
if (!CONSUMERS.has(metadata.consumer)) {
errors.push(ruleError('E_CONSUMER', file, `consumer 非法: ${metadata.consumer || '(空)'}`));
}
if (!BACKEND_STATUSES.has(metadata.backend_status)) {
errors.push(ruleError('E_BACKEND_STATUS', file, `backend_status 非法: ${metadata.backend_status || '(空)'}`));
}
if (!GATEWAY_STATUSES.has(metadata.gateway_status)) {
errors.push(ruleError('E_GATEWAY_STATUS', file, `gateway_status 非法: ${metadata.gateway_status || '(空)'}`));
}
if (API_CHANGE_TYPES.has(metadata.change_type) && metadata.backend_status === 'not_required') {
errors.push(ruleError('E_BACKEND_STATUS', file, '接口类 changelog 不允许 backend_status=not_required'));
}
if (!['deployed', 'not_required'].includes(metadata.backend_status)) {
errors.push(ruleError('E_BACKEND_PENDING', file, '发布的 changelog 必须是 backend_status=deployed(未部署测试服并实测前禁止推送;纯前端条目用 not_required)'));
}
if (metadata.gateway_status === 'pending') {
errors.push(ruleError('E_GATEWAY_PENDING', file, '发布的 changelog 不能保留 gateway_status=pending'));
}
for (const message of validateFrontendState(metadata)) {
errors.push(ruleError('E_FRONTEND_STATE', file, message));
}
if (metadata.consumer === 'internal' && metadata.frontend_status !== 'not_required') {
errors.push(ruleError('E_FRONTEND_STATE', file, 'internal consumer 必须使用 frontend_status=not_required'));
}
if (!isIsoDate(metadata.updated_at)) {
errors.push(ruleError('E_UPDATED_AT', file, 'updated_at 必须是真实的 YYYY-MM-DD 日期'));
}
const filename = path.posix.basename(file);
const issue = /^\d{2}_([1-9]\d*)_/.exec(filename)?.[1];
if (issue && metadata.ticket !== issue) {
errors.push(ruleError('E_TICKET_MISMATCH', file, `ticket=${metadata.ticket} 与文件名 Issue=${issue} 不一致`));
}
const filenameType = /-(新增接口|修改接口|删除接口|修复|前端缺陷|前端优化|前端修复)-(?:管理后台|小程序端)\.md$/.exec(filename)?.[1];
if (filenameType && metadata.change_type !== filenameType) {
errors.push(ruleError('E_TYPE_MISMATCH', file, `change_type=${metadata.change_type} 与文件名=${filenameType} 不一致`));
}
// 单花括号是 REST 路径参数惯例({orderId}),只拦真正的模板残留:双花括号、TODO、待补充
if (/\{\{[^{}\n]+\}\}|\bTODO\b|待补充/i.test(body)) {
errors.push(ruleError('E_PLACEHOLDER', file, '正文仍有 TODO、待补充或模板占位符'));
}
// 接口类条目必须逐项遵循根模板,保证前端不依赖 Swagger 或口头补充也能联调。
if (API_CHANGE_TYPES.has(metadata.change_type)) {
errors.push(...validateApiTemplate(file, metadata, body));
}
return errors;
}
export function collectChangedDocuments(records) {
return records
.filter(({ status }) => status !== 'D')
.map((record) => ({
path: record.targetPath,
isNew: record.status === 'A' || /^C\d{1,3}$/.test(record.status) || /^R\d{1,3}$/.test(record.status),
}))
.filter(({ path: file }) => controlledRootForPath(file) && file.endsWith('.md'));
}
export function runFrontmatterValidation(records, root = process.cwd()) {
const documents = collectChangedDocuments(records);
const errors = [];
for (const document of documents) {
let text;
try {
text = readFileSync(path.join(root, ...document.path.split('/')), 'utf8');
} catch (error) {
errors.push(ruleError('E_READ', document.path, `无法读取文件: ${error.message}`));
continue;
}
errors.push(...validateV2Document(document.path, text, { requireV2: document.isNew }));
}
return { checkedCount: documents.length, errors };
}
export function main(argv = process.argv.slice(2)) {
try {
const options = parseArguments(argv);
const records = parseNameStatusZ(diffFromOptions(options));
const result = runFrontmatterValidation(records);
if (result.errors.length > 0) {
for (const error of result.errors) {
console.error(`[${error.code}] ${error.path}: ${error.message}`);
}
console.error(`FAIL: ${result.errors.length} frontmatter error(s) in ${result.checkedCount} changelog file(s).`);
return 1;
}
console.log(`PASS: validated frontmatter for ${result.checkedCount} changed changelog file(s).`);
return 0;
} catch (error) {
console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
return 2;
}
}
const isCli = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
if (isCli) {
process.exitCode = main();
}