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\`\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 turning launch timing into an action item for the frontend', () => { const value = document().replace('## 七、不影响范围', `## 六.8、环境开关 - 上生产前请与后端确认这个开关的状态,否则提交会被拒。 ## 七、不影响范围`); const errors = validateV2Document(FILE, value, { requireV2: true }); assert.ok(errors.some(({ code }) => code === 'E_WAIT_LANGUAGE')); }); test('rejects asking who flips the production switch and when', () => { const value = document().replace('## 七、不影响范围', `## 六.8、环境开关 - 生产开关由谁开、何时上线,待排期确认后另行同步。 ## 七、不影响范围`); const errors = validateV2Document(FILE, value, { requireV2: true }); assert.ok(errors.some(({ code }) => code === 'E_WAIT_LANGUAGE')); }); // 阴性对照:撞上错误码时的运维处置建议是「怎么诊断」,不是「什么时候开工」,必须放行。 // 它与上一条只差一个「上生产前」,正好钉住本规则的分界线。 test('accepts runbook advice for an error code without launch timing', () => { const value = document().replace('## 七、不影响范围', `## 六.8、环境开关 - 收到 \`809009\` 即表示所在环境的开关没开,找后端确认该环境的开关——这不是数据问题,重试与补数据均无效。 - 窗口何时开过、保护何时开始生效,都可从时间线事件读到。 ## 七、不影响范围`); 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')); });