这个提交包含在:
@@ -30,6 +30,7 @@ const REQUIRED_KEYS = [
|
||||
'ticket',
|
||||
'title',
|
||||
'consumer',
|
||||
'author',
|
||||
'change_type',
|
||||
'backend_status',
|
||||
'gateway_status',
|
||||
@@ -38,9 +39,31 @@ const REQUIRED_KEYS = [
|
||||
'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 };
|
||||
@@ -68,6 +91,181 @@ function isIsoDateOrTime(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')) {
|
||||
@@ -165,11 +363,14 @@ export function validateV2Document(file, text, { requireV2 = false } = {}) {
|
||||
errors.push(ruleError('E_REQUIRED', file, `frontmatter 缺少 ${key}`));
|
||||
}
|
||||
}
|
||||
for (const key of ['ticket', 'title', 'consumer', 'change_type', 'backend_status', 'gateway_status', 'frontend_status', 'updated_at', 'base']) {
|
||||
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 || '(空)'}`));
|
||||
}
|
||||
@@ -213,13 +414,9 @@ export function validateV2Document(file, text, { requireV2 = false } = {}) {
|
||||
if (/\{\{[^{}\n]+\}\}|\bTODO\b|待补充/i.test(body)) {
|
||||
errors.push(ruleError('E_PLACEHOLDER', file, '正文仍有 TODO、待补充或模板占位符'));
|
||||
}
|
||||
// 接口类条目必须有接口清单章节与验证章节;修复/前端类条目结构自由,不强制
|
||||
// 接口类条目必须逐项遵循根模板,保证前端不依赖 Swagger 或口头补充也能联调。
|
||||
if (API_CHANGE_TYPES.has(metadata.change_type)) {
|
||||
const hasApiSection = /^##[^\n]*(变更接口|变更清单|变更内容|接口详情|变更点|接口变化|行为变化)/m.test(body);
|
||||
const hasEvidenceSection = /^##[^\n]*(验证|测试|复现|证据)/m.test(body);
|
||||
if (!hasApiSection || !hasEvidenceSection) {
|
||||
errors.push(ruleError('E_SECTIONS', file, '接口类正文缺少接口清单(变更接口/变更清单)或验证(验证证据/测试)章节'));
|
||||
}
|
||||
errors.push(...validateApiTemplate(file, metadata, body));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户