fix: frontmatter 未转义的引号让元数据对所有消费方隐形——修 19_7443 + 给校验器补语法层
changelog-filename-gate / validate (push) Failing after 1s
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>
这个提交包含在:
@@ -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}`));
|
||||
|
||||
在新工单中引用
屏蔽一个用户