- CHANGE_TYPES 扩:修复/前端缺陷/前端优化/前端修复(文件名+frontmatter 同步);纯前端条目 issue 段允许 frontend 字面量、backend_status 允许 not_required(接口类仍禁)
- E_PLACEHOLDER 只拦双花括号/TODO/待补充,不再误杀 {orderId} 等 REST 路径参数
- E_SECTIONS 仅约束接口类条目,章节名宽匹配(变更接口/变更清单/接口变化/行为变化等)
- 核心门禁保持硬:接口类必须 backend_status=deployed 才允许发布(E_BACKEND_PENDING)
- 背景:规则假阳性致 CI 长期常红被忽略,真违规(未部署即推送)淹没其中;npm test 46/46 绿
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
277 行
11 KiB
JavaScript
277 行
11 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',
|
|
'change_type',
|
|
'backend_status',
|
|
'gateway_status',
|
|
'frontend_status',
|
|
'frontend_owner',
|
|
'frontend_ref',
|
|
'target_release',
|
|
'verified_at',
|
|
'updated_at',
|
|
'base',
|
|
];
|
|
|
|
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));
|
|
}
|
|
|
|
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', 'change_type', 'backend_status', 'gateway_status', 'frontend_status', 'updated_at', 'base']) {
|
|
if (!metadata[key]?.trim()) {
|
|
errors.push(ruleError('E_REQUIRED', file, `${key} 不能为空`));
|
|
}
|
|
}
|
|
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、待补充或模板占位符'));
|
|
}
|
|
// 接口类条目必须有接口清单章节与验证章节;修复/前端类条目结构自由,不强制
|
|
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, '接口类正文缺少接口清单(变更接口/变更清单)或验证(验证证据/测试)章节'));
|
|
}
|
|
}
|
|
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();
|
|
}
|