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>
这个提交包含在:
+1
-1
@@ -12,7 +12,7 @@ frontend_owner: ""
|
|||||||
frontend_ref: ""
|
frontend_ref: ""
|
||||||
target_release: ""
|
target_release: ""
|
||||||
verified_at: ""
|
verified_at: ""
|
||||||
status_note: "订正件,不改代码,只改测试服 Nacos 配置 + 重启。18_7443 判 not_required 的理由是「809009 开关关闭,产品上产不出 TRANSFER 需求,4 个新错误码前端不可达」——该理由 2026-09-19 15:33:32 起已不成立:hl.order.requirement.transfer-kind-submit-enabled 在测试服 namespace=test 的 hl-order-service-v3-test.yml 被置 true 并随 order-v3 两实例重启(15:36:32/15:36:46)生效。本文撰写前二次复验(Nacos 直读 17:00:35 CST 仍为 true;SUPER_ADMIN 身份两次真实网关调用穿透了原先必现的 809009 分支,其中一次落库成功)。三条边界必须同时看:①代码默认值仍是 false(@Value 未改),生产环境未动;②RequirementService 无 @RefreshScope,改配置必须重启才生效,这次也确实重启了;③本文撰写过程中意外发现 18_7443/19_7443(未推送草稿) 关于「confirm/change 仍必现 605041」的既有说法本身已经过期——AC-24 修复(PR #7952, commit c527e48c3, 2026-09-18 18:55 合入 dev-v3)已经是当前测试服 order-v3(ca0656f1c)与 fleet(31fd5b5e6) 两个正在跑的字节的祖先提交,即该修复本身也已随各自服务的最近一次部署上线,但本文未对 confirm/change 端点做真实网关复测,这一句只有源码祖先关系与部署记录支撑,不构成端到端功能验证,请勿据此对外宣称"确认/改派已验证可用"。frontend_status 由 not_required 改判 pending:原判定的前提(不可达)已不成立,但生产环境仍不可达、且是否现在启动前端集成属产品排期决定,不是本文档能替 mmg 拍板的,故不写 not_required(意味着无需关注),也不写 claimed/implemented(没有证据),交给 mmg 自行判断是否现在启动。"
|
status_note: "订正件,不改代码,只改测试服 Nacos 配置 + 重启。18_7443 判 not_required 的理由是「809009 开关关闭,产品上产不出 TRANSFER 需求,4 个新错误码前端不可达」——该理由 2026-09-19 15:33:32 起已不成立:hl.order.requirement.transfer-kind-submit-enabled 在测试服 namespace=test 的 hl-order-service-v3-test.yml 被置 true 并随 order-v3 两实例重启(15:36:32/15:36:46)生效。本文撰写前二次复验(Nacos 直读 17:00:35 CST 仍为 true;SUPER_ADMIN 身份两次真实网关调用穿透了原先必现的 809009 分支,其中一次落库成功)。三条边界必须同时看:①代码默认值仍是 false(@Value 未改),生产环境未动;②RequirementService 无 @RefreshScope,改配置必须重启才生效,这次也确实重启了;③本文撰写过程中意外发现 18_7443/19_7443(未推送草稿) 关于「confirm/change 仍必现 605041」的既有说法本身已经过期——AC-24 修复(PR #7952, commit c527e48c3, 2026-09-18 18:55 合入 dev-v3)已经是当前测试服 order-v3(ca0656f1c)与 fleet(31fd5b5e6) 两个正在跑的字节的祖先提交,即该修复本身也已随各自服务的最近一次部署上线,但本文未对 confirm/change 端点做真实网关复测,这一句只有源码祖先关系与部署记录支撑,不构成端到端功能验证,请勿据此对外宣称\"确认/改派已验证可用\"。frontend_status 由 not_required 改判 pending:原判定的前提(不可达)已不成立,但生产环境仍不可达、且是否现在启动前端集成属产品排期决定,不是本文档能替 mmg 拍板的,故不写 not_required(意味着无需关注),也不写 claimed/implemented(没有证据),交给 mmg 自行判断是否现在启动。"
|
||||||
updated_at: "2026-09-19"
|
updated_at: "2026-09-19"
|
||||||
base: "dev-v3"
|
base: "dev-v3"
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -306,7 +306,68 @@ export function parseFrontmatter(text) {
|
|||||||
}
|
}
|
||||||
metadata[key] = fieldValue;
|
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) {
|
export function validateFrontendState(metadata) {
|
||||||
@@ -363,7 +424,7 @@ export function validateFrontendTransition(current, target, reason = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function validateV2Document(file, text, { requireV2 = false } = {}) {
|
export function validateV2Document(file, text, { requireV2 = false } = {}) {
|
||||||
const { metadata, body } = parseFrontmatter(text);
|
const { metadata, body, raw } = parseFrontmatter(text);
|
||||||
if (!metadata) {
|
if (!metadata) {
|
||||||
return requireV2 ? [ruleError('E_FRONTMATTER', file, '新增 changelog 缺少 YAML Front Matter')] : [];
|
return requireV2 ? [ruleError('E_FRONTMATTER', file, '新增 changelog 缺少 YAML Front Matter')] : [];
|
||||||
}
|
}
|
||||||
@@ -373,6 +434,11 @@ export function validateV2Document(file, text, { requireV2 = false } = {}) {
|
|||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
// 语法先于语义:frontmatter 读不了的话,下面所有按键取值的校验都是在校验一份
|
||||||
|
// 只有本脚本能读、消费方读不到的东西。这一条对存量文件同样生效(不受 requireV2 限制)。
|
||||||
|
for (const problem of validateFrontmatterSyntax(raw)) {
|
||||||
|
errors.push(ruleError('E_FRONTMATTER_SYNTAX', file, problem));
|
||||||
|
}
|
||||||
for (const key of REQUIRED_KEYS) {
|
for (const key of REQUIRED_KEYS) {
|
||||||
if (!(key in metadata)) {
|
if (!(key in metadata)) {
|
||||||
errors.push(ruleError('E_REQUIRED', file, `frontmatter 缺少 ${key}`));
|
errors.push(ruleError('E_REQUIRED', file, `frontmatter 缺少 ${key}`));
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户