#!/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、待补充 // 代码围栏与反引号内文本不参与占位符检测:接口枚举值本身可能就叫 TODO(团期芯片聚合态 TODO/DOING/DONE,#7204),契约必须能原样写枚举值。 const proseBody = body.replace(/```[\s\S]*?```/g, '').replace(/`[^`\n]*`/g, ''); if (/\{\{[^{}\n]+\}\}|\bTODO\b|待补充/i.test(proseBody)) { 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 }; } /** * 提交前自检模式:`--files a.md b.md`(或逗号分隔)按「新增文件」口径校验工作区里的 changelog, * 让作者/Agent 不用先 commit 就能跑到与 pre-push 门禁完全相同的规则(hl-workflow changelog_workflow.py lint 也走这里)。 */ export function runFilesValidation(files, root = process.cwd()) { const errors = []; let checkedCount = 0; for (const raw of files) { const absolute = path.resolve(root, raw); const relative = path.relative(root, absolute).split(path.sep).join('/'); if (!controlledRootForPath(relative) || !relative.endsWith('.md')) { errors.push(ruleError('E_PATH', relative, '不在受控 changelog 目录内(changelogs/ 或 changelogs-v2/)')); continue; } let text; try { text = readFileSync(absolute, 'utf8'); } catch (error) { errors.push(ruleError('E_READ', relative, `无法读取文件: ${error.message}`)); continue; } checkedCount += 1; errors.push(...validateV2Document(relative, text, { requireV2: true })); } return { checkedCount, errors }; } export function main(argv = process.argv.slice(2)) { try { if (argv[0] === '--files') { const files = argv.slice(1).flatMap((item) => item.split(',')).filter(Boolean); if (files.length === 0) { throw new Error('--files 需要至少一个文件路径'); } const result = runFilesValidation(files); 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} changelog file(s) (--files).`); return 0; } 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(); }