changelog-filename-gate / validate (push) Failing after 1s
今天上午装这条守卫时只想到「等/待/另发」这一种形态,当天下午就被另一种形态绕过去了: 20_7443 正文写着「上生产前请与后端确认这个开关的状态」与「生产环境未开」, mmg 据此来问上线时间、并要求「后端把生产开关打开」——而守卫全绿,因为这两句 一个词表词都没用上。它们把不确定性包装成了「请你去确认」,语法换了,作用一样: 读者只能停在那里等一个他查不到的状态。 判据仍是那一句:这条影响他「怎么写代码」,还是只影响他「什么时候开始写」。 上线时点属后者。前端需不需要同步上线,由 frontend_action_required 与模板里 「前端是否必须同步上线」那个结构化字段承载,正文自由文本里不该再出现。 词表先对全仓 1068 份 changelog 实跑,只留零命中且零正当用法的 12 个词。剔除两个: 「何时开」 —— 误伤「保护何时开始生效」「窗口何时开过」 「生产上线」—— 误伤 07_5640「生产上线需配 annual-direct-plan-id」,那是真契约边界 部署时间戳没做成规则:该形态全仓 0 命中,分辨力无从验证,而必须放行的 「带时刻实测取证句」有 690 处——判据的误伤面远大于收益时,门禁只会教人绕开它。 这一类只能靠 §2.1 的条文和复盘接住,机器接不住,如实记在注释里。 测试:新增 2 条阳性 + 1 条阴性对照,阴性那条与阳性只差一个「上生产前」, 用来钉住分界线(「收到 809009 找后端确认该环境的开关」是运维处置,必须放行)。 npm test 59 项 58 绿;唯一的红是存量的 E_ALIAS_STATE(11_7510,与本次无关)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
689 行
29 KiB
JavaScript
689 行
29 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');
|
|
}
|
|
|
|
// --verbose 时把逐端点命中行打到 stdout。默认 false ⇒ 输出与加它之前逐字节相同。
|
|
// 为什么需要它:本校验器的端点解析结果原本只在「清单与详情对不上」时才进 errors,
|
|
// 成功路径零输出,于是「每个端点都被校验过」这件事无法取证(#7439 AC-19)。
|
|
let VERBOSE_ENDPOINTS = false;
|
|
|
|
export function setVerboseEndpoints(on) {
|
|
VERBOSE_ENDPOINTS = Boolean(on);
|
|
}
|
|
|
|
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 (VERBOSE_ENDPOINTS) {
|
|
detailMatches.forEach((match, index) => {
|
|
console.log(`[endpoint] ${file} #${index + 1} ${match[1]} ${match[2].trim()}`);
|
|
});
|
|
console.log(`[endpoint-count] ${file} ${detailMatches.length}`);
|
|
}
|
|
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), raw: value.slice(4, end) };
|
|
}
|
|
|
|
/**
|
|
* 校验 frontmatter 是不是**合法 YAML**。
|
|
*
|
|
* 为什么需要这一层:上面的 parseFrontmatter 是朴素行解析(按第一个 ':' 切、两端引号成对就剥),
|
|
* 它对「这份 YAML 下游读不读得了」零分辨力。2026-09-20 全仓实测 8 份文件因此长期处于
|
|
* 「本校验器 PASS,而 Gitea 与任何 YAML 解析器都读不到元数据」的状态——页面上看起来就是
|
|
* 「这份 changelog 没有元数据」,而没有任何一处报错。校验器回答的是自己那套宽松读法,
|
|
* 不是消费方的读法。
|
|
*
|
|
* 本仓刻意零依赖,所以不引 YAML 库,改为按实际出过的两类伤写定向判据:
|
|
* ① 双引号标量内部出现**未转义**的 ASCII 双引号 ⇒ YAML 在第一个处截断(8 份里 7 份是这个);
|
|
* ② 反斜杠后跟着非法转义字符(如 heredoc 残留的 `\【`)⇒ scanner 报错。
|
|
* 外加「以 " 开头却不以 " 结尾」这种收尾引号丢失的形态。
|
|
*/
|
|
export function validateFrontmatterSyntax(raw) {
|
|
const BACKSLASH = String.fromCharCode(92);
|
|
const VALID_ESCAPE = new Set(
|
|
['0', 'a', 'b', 't', 'n', 'v', 'f', 'r', 'e', '"', '/', BACKSLASH, 'N', '_', 'L', 'P', 'x', 'u', 'U', ' '],
|
|
);
|
|
const problems = [];
|
|
for (const line of String(raw ?? '').split('\n')) {
|
|
const separator = line.indexOf(':');
|
|
if (separator < 0) {
|
|
continue;
|
|
}
|
|
const key = line.slice(0, separator).trim();
|
|
const value = line.slice(separator + 1).trim();
|
|
if (!value.startsWith('"')) {
|
|
continue;
|
|
}
|
|
if (value.length < 2 || !value.endsWith('"')) {
|
|
problems.push(`${key} 的值以双引号开头却不以双引号结尾(收尾引号丢失?)`);
|
|
continue;
|
|
}
|
|
const inner = value.slice(1, -1);
|
|
let bare = 0;
|
|
let badEscape = '';
|
|
for (let i = 0; i < inner.length; i += 1) {
|
|
const ch = inner[i];
|
|
if (ch === BACKSLASH) {
|
|
const next = inner[i + 1] ?? '';
|
|
if (!VALID_ESCAPE.has(next)) {
|
|
badEscape ||= next;
|
|
}
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
bare += 1;
|
|
}
|
|
}
|
|
if (bare > 0) {
|
|
problems.push(`${key} 内部有 ${bare} 个未转义的 ASCII 双引号;YAML 会在第一个处截断整块 frontmatter`);
|
|
}
|
|
if (badEscape) {
|
|
problems.push(`${key} 内部有非法转义序列(反斜杠后跟 ${JSON.stringify(badEscape)});要字面反斜杠请写两个`);
|
|
}
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
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}`];
|
|
}
|
|
|
|
// 🔴 E_WAIT_LANGUAGE(2026-09-21 wx 第二次点名:「不要在changelog里写让前端等待部署 这不是你第一回犯错了」)
|
|
// 背景:20_7443 的 frontmatter 已是 backend_status: deployed、CI 全绿,但正文 status_note 结尾写着
|
|
// 「该缺陷已在修……修好后另发交接件」。mmg 因此一直没动工,直到隔天来问「这个是有啥问题吗 还是没做到呢」。
|
|
// 门禁只校验 frontmatter,看不见自由文本里的这句话——「deployed + 校验绿」并不代表这份交接件可执行。
|
|
// 消费方也没有能力消解这种不确定性:他查不了我们的部署状态、看不到 dev-v3、不知道「另发」是哪天,
|
|
// 读到「等」就只能等,而且是静默地等(文件推了、看起来已交付,唯一的异常信号是有人在安静空转)。
|
|
// 正确顺序是「部署测试环境 → 实测验证 → 再推 changelog」,把不确定性关掉,而不是写进正文交给前端。
|
|
// ⚠️ 本规则只拦「我方在制品」,不拦「契约自身的覆盖边界」:
|
|
// 该写 —— 灰度开关状态、前置字段要求、会抛的错误码、已知缺口的工单号(前端照样能开工,只是知道边界在哪)
|
|
// 不该写 —— 我们还没部署 / 还在修 / 稍后另发(前端只能停手)
|
|
// 分界线:这条影响他「怎么写代码」,还是只影响他「什么时候开始写」?后者一律删掉。
|
|
const WAIT_LANGUAGE_PHRASES = [
|
|
// 一、部署状态对冲
|
|
'等部署', '待部署', '未部署', '部署后再', '部署完再', '部署完成后再', '是否已部署',
|
|
'未滚动到测试', '待后端部署', '后端部署后', '部署状态未核', '部署状态未知',
|
|
'自行确认部署', 'deploy-status.sh 确认',
|
|
// 二、未来交付承诺(兑现时点前端判断不了,只能等)
|
|
'稍后另发', '修好后另发', '另发交接件', '后续补发', '另行补发', '后续再发', '届时再发',
|
|
'后续订正', '以后续', '见后续', '后续另', '另行通知', '再行通知', '进度以后', '进展以后',
|
|
// 三、直接叫停对接
|
|
'请先等', '暂缓对接', '暂不要对接', '先不要对接', '先别对接',
|
|
'我们这边还在', '该缺陷已在修',
|
|
// 四、把「什么时候上线」写成派给前端的动作项
|
|
// (2026-09-21 复盘补:20_7443 写了「上生产前请与后端确认这个开关的状态」,
|
|
// mmg 因此来问上线时间与「请后端把生产开关打开」。上面三类词表一个都没拦住——
|
|
// 它们匹配的是「等/待/另发」,而这句把不确定性包装成了「请你去确认」。
|
|
// 上线时点不影响「怎么写代码」,只影响「什么时候开始写」,属本规则该删的那一半。
|
|
// 前端需不需要同步上线,有 frontend_action_required 与模板里「前端是否必须同步上线」
|
|
// 那个结构化字段承载,正文自由文本里不再重复。)
|
|
'上生产前请', '上生产前与', '上线前请', '上线前与',
|
|
'上线时间', '上线排期', '上生产时间', '何时上线',
|
|
'由谁开', '谁来开', '排期确定后', '排期确认后',
|
|
// ⚙ 词表已对全仓 1068 份 changelog 实跑,以上全部零命中。两个候选词被剔除:
|
|
// 「何时开」——误伤「保护何时开始生效」「窗口何时开过」
|
|
// 「生产上线」——误伤 07_5640「生产上线需配 annual-direct-plan-id」(真契约边界)
|
|
// 部署时间戳没做成规则:该形态全仓 0 命中(分辨力验不了),
|
|
// 而必须放行的「带时刻实测取证句」有 690 处,误伤面远大于收益。
|
|
// ⚠️ 不要把「暂不可用 / 暂时不可用」加回来:2026-09-21 对全仓 1968 份 changelog 实测,
|
|
// 它命中的几乎全是错误码表与响应示例里的**文案**(如「584105 结算字典暂时不可用,请稍后重试」),
|
|
// 那正是本规则必须放行的契约内容。判据的分辨力不够时,门禁只会教人绕开它。
|
|
];
|
|
|
|
// 「等待」本身在业务描述里是合法的(如「前端需轮询等待支付回调」),只有当它指向我方在制品时才算违规。
|
|
// 所以对它做共现判定,而不是裸词匹配——裸词会逼作者为了过门禁把该写的业务语义一起删掉。
|
|
const WAIT_VERB = '等待';
|
|
const WAIT_SUBJECT_WORDS = ['部署', '后端', '我们', '上线', '修复', '另发', '发版', '滚动'];
|
|
|
|
/**
|
|
* 校验正文没有「让前端等我们」的措辞。命中即硬失败——这类句子会让消费方无限期停工,
|
|
* 而 frontmatter 门禁在它面前是全绿的,没有任何其他信号会暴露它。
|
|
*
|
|
* @param {string} file 相对路径,仅用于报错定位
|
|
* @param {string} body frontmatter 之后的正文
|
|
* @returns {Array} 违规列表,每行最多报一条
|
|
*/
|
|
function validateNoWaitLanguage(file, body) {
|
|
const errors = [];
|
|
const lines = String(body || '').split(/\r?\n/);
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const line = lines[index];
|
|
let phrase = WAIT_LANGUAGE_PHRASES.find((candidate) => line.includes(candidate));
|
|
if (!phrase && line.includes(WAIT_VERB)) {
|
|
const subject = WAIT_SUBJECT_WORDS.find((word) => line.includes(word));
|
|
if (subject) {
|
|
phrase = `${WAIT_VERB}…${subject}`;
|
|
}
|
|
}
|
|
if (!phrase) {
|
|
continue;
|
|
}
|
|
errors.push(ruleError(
|
|
'E_WAIT_LANGUAGE',
|
|
file,
|
|
`第 ${index + 1} 行出现让前端等待的措辞「${phrase}」:交接件只描述「已就绪的契约」`
|
|
+ '与「前端调用时会撞上的限定」,不描述我方在制品。先部署测试环境并实测,再推 changelog;'
|
|
+ `确实没就绪的部分整段删掉,不要写成「稍后另发」。原文:${line.trim().slice(0, 80)}`,
|
|
));
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
export function validateV2Document(file, text, { requireV2 = false } = {}) {
|
|
const { metadata, body, raw } = 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 = [];
|
|
// 语法先于语义:frontmatter 读不了的话,下面所有按键取值的校验都是在校验一份
|
|
// 只有本脚本能读、消费方读不到的东西。这一条对存量文件同样生效(不受 requireV2 限制)。
|
|
for (const problem of validateFrontmatterSyntax(raw)) {
|
|
errors.push(ruleError('E_FRONTMATTER_SYNTAX', file, problem));
|
|
}
|
|
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、待补充或模板占位符'));
|
|
}
|
|
// 正文不得出现「让前端等我们」的措辞;与 change_type 无关——前端条目同样适用。
|
|
errors.push(...validateNoWaitLanguage(file, body));
|
|
|
|
// 接口类条目必须逐项遵循根模板,保证前端不依赖 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)) {
|
|
// --verbose 可出现在任意位置;剔除后再走原有解析,避免打乱 argv[0] === '--files' 的判断。
|
|
if (argv.includes('--verbose')) {
|
|
setVerboseEndpoints(true);
|
|
argv = argv.filter((item) => item !== '--verbose');
|
|
}
|
|
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();
|
|
}
|