changelog-filename-gate / validate (push) Failing after 2s
2026-09-21 wx 第二次点名:「不要在changelog里写让前端等待部署 这不是你第一回犯错了 工作流是你部署完测试环境推送changelog」。 §2.1 的 backend_status 门禁挡得住预告式推送,挡不住这一类:20_7443 的 frontmatter 已经是 deployed、CI 全绿,但正文 status_note 结尾写着「该缺陷已在修……修好后另发 交接件」。mmg 因此一直没动工,隔天才来问「这个是有啥问题吗 还是没做到呢」——门禁 只看 frontmatter,看不见自由文本里的这句话,所以「deployed + 校验绿」并不代表这份 交接件可执行。消费方也没有能力消解这种不确定性:他查不了我们的部署状态、看不到 dev-v3、不知道「另发」是哪天,读到「等」就只能等,而且是静默地等。 - scripts/validate-changelog-frontmatter.mjs:新增 validateNoWaitLanguage,对所有 v2 文档逐行扫描(与 change_type 无关,前端条目同样适用)。三类措辞:部署状态对冲、 未来交付承诺、直接叫停对接;「等待」做共现判定而非裸词匹配,避免把「前端需轮询 等待支付回调」这类业务语义一起拦掉。前向引用(「以后续订正为准」「见后续订正」) 一并封住——它和「修好后另发」是同一件事换个说法,实测被绕过一次。 - tests:4 个用例,含 1 个阴性对照(灰度开关状态、已知缺口工单号、业务流程里的等待 必须放行),防止作者为了过门禁把该写的契约边界一起删掉。 - BACKEND_CHANGELOG_DELIVERY_GUIDE.md §2.1:写明规则、背景与那条分界线——这条影响 他「怎么写代码」,还是只影响他「什么时候开始写」?后者一律删。 阳性对照:对已推送的 HEAD 版 20_7443 跑新规则,命中 2 处(第 308、451 行「修好后另发」), 即它能抓住真实发生过的那次。既有 changelog-path-aliases 测试对 11_7510 的 2 条 E_ALIAS_STATE 红是本次改动之前就存在的,与本提交无关,pre-push 钩子也不跑该用例。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
268 行
12 KiB
JavaScript
268 行
12 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
parseFrontmatter,
|
|
runFrontmatterValidation,
|
|
validateFrontendState,
|
|
validateFrontendTransition,
|
|
validateV2Document,
|
|
} from '../scripts/validate-changelog-frontmatter.mjs';
|
|
|
|
|
|
const FILE = 'changelogs-v2/2026-07/24_5218_工作流治理-修改接口-管理后台.md';
|
|
|
|
function metadata(overrides = {}) {
|
|
return {
|
|
schema: 'hl-changelog/v2',
|
|
ticket: '5218',
|
|
title: '工作流治理',
|
|
consumer: 'admin',
|
|
author: 'lc(GIT)',
|
|
change_type: '修改接口',
|
|
backend_status: 'deployed',
|
|
gateway_status: 'verified',
|
|
frontend_status: 'pending',
|
|
frontend_owner: '',
|
|
frontend_ref: '',
|
|
target_release: '',
|
|
verified_at: '',
|
|
status_note: '',
|
|
updated_at: '2026-07-24',
|
|
base: 'dev-v3',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function document(overrides = {}) {
|
|
const fields = metadata(overrides);
|
|
const frontmatter = Object.entries(fields)
|
|
.map(([key, value]) => `${key}: "${value}"`)
|
|
.join('\n');
|
|
return `---\n${frontmatter}\n---\n\n# 工作流治理\n\n## 二、变更接口清单\n\n| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |\n|---|---|---|---|---|---|\n| 1 | 保存配置 | POST | \`/admin/workflow/config\` | 修改请求 | 保存工作流配置 |\n\n## 三、接口详情\n\n### 1. 保存配置 \`POST /admin/workflow/config\`\n\n**VO**: \`WorkflowConfigReqVO / String\`\n\n#### 使用场景\n\n- 管理员保存工作流配置。\n\n#### 入参\n\n| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |\n|---|---|---|---|---|---|\n| name | Body | String | ✅ | 非空 | 配置名称 |\n\n#### 出参 \`Result<String>\`\n\n| 字段 | 类型 | 说明 |\n|---|---|---|\n| data | String | 配置 ID |\n\n#### 请求示例\n\n\`\`\`json\n{ "name": "审批流" }\n\`\`\`\n\n#### 响应示例\n\n\`\`\`json\n{ "code": 200, "message": "成功", "data": "1", "success": true }\n\`\`\`\n\n#### 空数据 / 降级响应\n\n成功时一定返回字符串 ID。\n\n#### 错误响应\n\n\`\`\`json\n{ "code": 400, "message": "name 不能为空", "data": null, "success": false }\n\`\`\`\n\n#### 业务边界\n\n- 业务失败也必须检查响应体 code。\n\n## 四、契约约束与正确调用方式\n\n- 请求体必须使用 JSON。\n\n## 五、数据库行为\n\n- 成功请求保存一份配置,失败请求不产生业务写入。\n\n## 六、边界行为\n\n- 未登录返回 401。\n\n## 六.6、修改前后对比\n\n| 字段 | 改前 | 改后 |\n|---|---|---|\n| name | 可为空 | 必填 |\n\n## 六.7、影响评估\n\n- **是否破坏向后兼容**: 否\n- **前端是否必须同步上线**: 是\n- **前端 workaround 清理点**: 无\n\n## 七、不影响范围\n\n- 查询接口不变。\n\n## 八、测试环境已验证\n\n- POST /admin/workflow/config → 200 ✓\n\n## 十、相关文档\n\n- Issue: #5218\n\n## 关联 / 联系人\n\n- **后端负责人**: @lc\n`;
|
|
}
|
|
|
|
function incompleteApiDocument() {
|
|
const fields = metadata();
|
|
const frontmatter = Object.entries(fields)
|
|
.map(([key, value]) => `${key}: "${value}"`)
|
|
.join('\n');
|
|
return `---\n${frontmatter}\n---\n\n# 工作流治理\n\n## 变更接口\n\n- POST /admin/workflow/config。\n\n## 验证证据\n\n- 自动化测试通过。\n`;
|
|
}
|
|
|
|
test('parses quoted flat YAML frontmatter', () => {
|
|
const parsed = parseFrontmatter(document());
|
|
assert.equal(parsed.metadata.schema, 'hl-changelog/v2');
|
|
assert.equal(parsed.metadata.frontend_status, 'pending');
|
|
});
|
|
|
|
test('CHANGELOG_TEMPLATE.md contains every section enforced for API handoffs', () => {
|
|
const template = readFileSync(
|
|
new URL('../CHANGELOG_TEMPLATE.md', import.meta.url),
|
|
'utf8',
|
|
);
|
|
for (const value of [
|
|
'author: "{推送者登录名}(GIT)"',
|
|
'## 二、变更接口清单',
|
|
'## 三、接口详情',
|
|
'#### 使用场景',
|
|
'#### 入参',
|
|
'#### 出参',
|
|
'#### 请求示例',
|
|
'#### 响应示例',
|
|
'#### 空数据 / 降级响应',
|
|
'#### 错误响应',
|
|
'#### 业务边界',
|
|
'## 四、契约约束与正确调用方式',
|
|
'## 五、数据库行为',
|
|
'## 六、边界行为',
|
|
'## 六.6、修改前后对比',
|
|
'## 六.7、影响评估',
|
|
'## 七、不影响范围',
|
|
'## 八、测试环境已验证',
|
|
'## 十、相关文档',
|
|
'## 关联 / 联系人',
|
|
]) {
|
|
assert.ok(template.includes(value), `template missing ${value}`);
|
|
}
|
|
});
|
|
|
|
test('accepts a complete v2 handoff with pending frontend consumption', () => {
|
|
assert.deepEqual(validateV2Document(FILE, document(), { requireV2: true }), []);
|
|
});
|
|
|
|
test('rejects an API handoff that does not follow CHANGELOG_TEMPLATE.md', () => {
|
|
const errors = validateV2Document(FILE, incompleteApiDocument(), { requireV2: true });
|
|
assert.ok(errors.some(({ code }) => code === 'E_API_TEMPLATE'));
|
|
});
|
|
|
|
test('rejects an endpoint detail without a required self-contained contract block', () => {
|
|
const value = document().replace('#### 错误响应', '#### 异常说明');
|
|
const errors = validateV2Document(FILE, value, { requireV2: true });
|
|
assert.ok(errors.some(({ code }) => code === 'E_API_DETAIL'));
|
|
});
|
|
|
|
test('requires a use case and explicit empty/degraded behavior for every endpoint', () => {
|
|
const value = document()
|
|
.replace('#### 使用场景', '#### 调用时机')
|
|
.replace('#### 空数据 / 降级响应', '#### 无数据');
|
|
const errors = validateV2Document(FILE, value, { requireV2: true });
|
|
assert.ok(errors.some(({ code, message }) => (
|
|
code === 'E_API_DETAIL'
|
|
&& message.includes('使用场景')
|
|
&& message.includes('空数据 / 降级响应')
|
|
)));
|
|
});
|
|
|
|
test('rejects a mismatch between the API list and endpoint detail headings', () => {
|
|
const value = document().replace(
|
|
'### 1. 保存配置 `POST /admin/workflow/config`',
|
|
'### 1. 保存配置 `POST /admin/workflow/other`',
|
|
);
|
|
const errors = validateV2Document(FILE, value, { requireV2: true });
|
|
assert.ok(errors.some(({ code }) => code === 'E_API_ENDPOINTS'));
|
|
});
|
|
|
|
test('requires the template author format and write-operation database section', () => {
|
|
const invalidAuthor = validateV2Document(
|
|
FILE,
|
|
document({ author: 'lc' }),
|
|
{ requireV2: true },
|
|
);
|
|
assert.ok(invalidAuthor.some(({ code }) => code === 'E_AUTHOR'));
|
|
|
|
const withoutDatabaseBehavior = document().replace(
|
|
'## 五、数据库行为',
|
|
'## 五、持久化说明',
|
|
);
|
|
const errors = validateV2Document(FILE, withoutDatabaseBehavior, { requireV2: true });
|
|
assert.ok(errors.some(({ code }) => code === 'E_API_TEMPLATE'));
|
|
});
|
|
|
|
test('rejects a handoff that tells the frontend to wait for us', () => {
|
|
const value = document().replace('## 七、不影响范围', `## 六.8、当前状态
|
|
|
|
- 🔴 该缺陷已在修(属 #7990 那一族),修好后另发交接件。
|
|
|
|
## 七、不影响范围`);
|
|
const errors = validateV2Document(FILE, value, { requireV2: true });
|
|
assert.ok(errors.some(({ code }) => code === 'E_WAIT_LANGUAGE'));
|
|
});
|
|
|
|
test('rejects forward references that only rename the waiting', () => {
|
|
const value = document().replace('## 七、不影响范围', `## 六.8、当前状态
|
|
|
|
- 该链路属 #7990 那一族,处理进展以后续订正为准,本节不复述内部处理状态。
|
|
|
|
## 七、不影响范围`);
|
|
const errors = validateV2Document(FILE, value, { requireV2: true });
|
|
assert.ok(errors.some(({ code }) => code === 'E_WAIT_LANGUAGE'));
|
|
});
|
|
|
|
test('rejects hedging about whether the backend is deployed', () => {
|
|
const value = document().replace('## 七、不影响范围', `## 六.8、当前状态
|
|
|
|
- 已合入 dev-v3;是否已滚动到测试服未核,取证前请自行跑 deploy-status.sh 确认。
|
|
|
|
## 七、不影响范围`);
|
|
const errors = validateV2Document(FILE, value, { requireV2: true });
|
|
assert.ok(errors.some(({ code }) => code === 'E_WAIT_LANGUAGE'));
|
|
});
|
|
|
|
// 阴性对照:契约自身的覆盖边界(灰度开关状态、已知缺口的工单号、业务流程里的等待)不是「让前端等我们」。
|
|
// 它们影响的是「怎么写代码」而不是「什么时候开始写」,必须放行——否则作者会为了过门禁把该写的限定一起删掉,
|
|
// 那正好撞上「产出物必须把自己的覆盖范围写在脸上」那条相反的要求。
|
|
test('accepts coverage limits that do not ask the frontend to wait', () => {
|
|
const value = document().replace('## 七、不影响范围', `## 六.8、已知边界
|
|
|
|
- 灰度开关 \`hl.order.requirement.transfer-kind-submit-enabled\` 在生产环境尚未开启,开启属独立运维动作。
|
|
- TRANSFER-only 订单在 9 个下游消费方无产出,已记 #8056,不影响本次对接。
|
|
- 支付结果异步返回,前端需轮询等待支付回调。
|
|
|
|
## 七、不影响范围`);
|
|
const errors = validateV2Document(FILE, value, { requireV2: true });
|
|
assert.deepEqual(errors.filter(({ code }) => code === 'E_WAIT_LANGUAGE'), []);
|
|
});
|
|
|
|
test('rejects backend and gateway pending at publication', () => {
|
|
const errors = validateV2Document(
|
|
FILE,
|
|
document({ backend_status: 'pending', gateway_status: 'pending' }),
|
|
{ requireV2: true },
|
|
);
|
|
assert.ok(errors.some(({ code }) => code === 'E_BACKEND_PENDING'));
|
|
assert.ok(errors.some(({ code }) => code === 'E_GATEWAY_PENDING'));
|
|
});
|
|
|
|
test('requires frontend evidence as status advances', () => {
|
|
assert.deepEqual(
|
|
validateFrontendState(metadata({ frontend_status: 'claimed' })),
|
|
['claimed 必须填写 frontend_owner'],
|
|
);
|
|
assert.deepEqual(
|
|
validateFrontendState(metadata({
|
|
frontend_status: 'implemented',
|
|
frontend_owner: 'frontend-team',
|
|
frontend_ref: 'mmg/hl-ui@abc1234',
|
|
})),
|
|
[],
|
|
);
|
|
assert.ok(
|
|
validateFrontendState(metadata({
|
|
frontend_status: 'verified',
|
|
frontend_owner: 'frontend-team',
|
|
frontend_ref: 'mmg/hl-ui@abc1234',
|
|
target_release: 'prod-2026.07.24',
|
|
verified_at: '2026-02-30',
|
|
})).includes('verified_at 必须是 ISO 日期或时间'),
|
|
);
|
|
});
|
|
|
|
test('rejects skipped transitions and requires a rollback reason', () => {
|
|
assert.ok(validateFrontendTransition('pending', 'implemented').length > 0);
|
|
assert.deepEqual(validateFrontendTransition('pending', 'claimed'), []);
|
|
assert.ok(validateFrontendTransition('released', 'implemented').length > 0);
|
|
assert.deepEqual(
|
|
validateFrontendTransition('released', 'implemented', '测试发布已回滚'),
|
|
[],
|
|
);
|
|
});
|
|
|
|
test('new changelog requires v2 while a modified legacy file remains compatible', () => {
|
|
const root = mkdtempSync(path.join(tmpdir(), 'hl-frontmatter-'));
|
|
try {
|
|
const file = path.join(root, ...FILE.split('/'));
|
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
writeFileSync(file, '# legacy\n');
|
|
const newResult = runFrontmatterValidation([{ status: 'A', targetPath: FILE }], root);
|
|
assert.ok(newResult.errors.some(({ code }) => code === 'E_FRONTMATTER'));
|
|
const modifiedResult = runFrontmatterValidation([{ status: 'M', targetPath: FILE }], root);
|
|
assert.deepEqual(modifiedResult.errors, []);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('detects metadata and filename mismatches', () => {
|
|
const errors = validateV2Document(
|
|
FILE,
|
|
document({ ticket: '9999', change_type: '新增接口' }),
|
|
{ requireV2: true },
|
|
);
|
|
assert.ok(errors.some(({ code }) => code === 'E_TICKET_MISMATCH'));
|
|
assert.ok(errors.some(({ code }) => code === 'E_TYPE_MISMATCH'));
|
|
});
|
|
|
|
test('rejects unsupported consumers and impossible dates', () => {
|
|
const errors = validateV2Document(
|
|
FILE,
|
|
document({ consumer: 'browser', updated_at: '2026-02-30' }),
|
|
{ requireV2: true },
|
|
);
|
|
assert.ok(errors.some(({ code }) => code === 'E_CONSUMER'));
|
|
assert.ok(errors.some(({ code }) => code === 'E_UPDATED_AT'));
|
|
});
|