fix: frontmatter 未转义的引号让元数据对所有消费方隐形——修 19_7443 + 给校验器补语法层
changelog-filename-gate / validate (push) Failing after 1s

现象:Gitea 上打开这份 changelog 看不到元数据表,页面表现就是「这份文件没有元数据」,
而文件里其实写着完整的 16 个键。

根因:status_note 是 YAML 双引号标量,正文里夹着未转义的 ASCII 双引号
(...请勿据此对外宣称"确认/改派已验证可用")。YAML 在第一个内部引号处截断,
整块 frontmatter 解析失败,Gitea 于是静默不渲染。

为什么一直没人发现:本仓校验器的 parseFrontmatter 是朴素行解析——按第一个冒号切开、
两端引号成对就剥掉——它对「这份 YAML 下游读不读得了」零分辨力。于是这类文件长期处于
「校验器 PASS,而 Gitea 与任何 YAML 解析器都读不到」的状态,没有任何一处报错。
校验器回答的是自己那套宽松读法,不是消费方的读法。

改动两部分:
一、修 19_7443 那两个引号。转义只改字节不改语义,验过三条:改后 yaml.safe_load 成功、
    解析出的值与作者本意逐字相同、正文一个字节没动。
二、给校验器补上它完全没有的那一层:validateFrontmatterSyntax,新错误码
    E_FRONTMATTER_SYNTAX。本仓刻意零依赖,所以不引 YAML 库,按实际出过的三类伤写
    定向判据:内部未转义双引号 / 收尾引号丢失 / 非法转义序列。语法校验排在所有按键
    取值的校验之前——frontmatter 读不了的话,后面那些校验都是在校验一份只有本脚本
    能读的东西。

自验两个方向:三种人造伤各自被拦(exit=1);全仓 1030 份逐批跑完,新增错误码
E_FRONTMATTER_SYNTAX 命中 0 次,无连带误伤;独立口径用真 YAML 解析器扫全仓,
546 份带 frontmatter 的全部可解析。

⚠️ 同一毛病另有 7 份存量文件未修(05_5530 / 08_5592 / 06_7149 / 10_7328 / 13_7625 /
15_7327 / 15_fund-account)。它们的元数据同样对 Gitea 隐形,修法已验证可行,
但每份都带着 1~3 条与本次无关的历史门禁错误(E_FRONTEND_STATE「verified 必须填写
target_release」为主,另有 E_AUTHOR / E_API_TEMPLATE / E_BACKEND_STATUS),
推不上去。补那些字段需要编造内容,不做。
🔴 值得记一笔的因果:正是门禁这个严格度让这 8 份一直坏着——谁想顺手修一下,
就会撞上一堆与自己无关的历史错误然后放弃。修复脚本留在
D:/work2/_scratch/changelog-fm-fix/,等 wx 决定是给存量文件开豁免还是补齐字段。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这个提交包含在:
API Changelog Bot
2026-09-20 11:04:32 +08:00
共同撰写人 Claude Opus 5
父节点 8d644fc8f3
当前提交 ae069b00a9
共修改 2 个文件,包含 69 行新增和 3 行删除
+68 -2
查看文件
@@ -306,7 +306,68 @@ export function parseFrontmatter(text) {
}
metadata[key] = fieldValue;
}
return { metadata, body: value.slice(end + 5) };
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) {
@@ -363,7 +424,7 @@ export function validateFrontendTransition(current, target, reason = '') {
}
export function validateV2Document(file, text, { requireV2 = false } = {}) {
const { metadata, body } = parseFrontmatter(text);
const { metadata, body, raw } = parseFrontmatter(text);
if (!metadata) {
return requireV2 ? [ruleError('E_FRONTMATTER', file, '新增 changelog 缺少 YAML Front Matter')] : [];
}
@@ -373,6 +434,11 @@ export function validateV2Document(file, text, { requireV2 = false } = {}) {
: [];
}
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}`));