feat: 增加 changelog 前端消费状态与交接指南 (#5218) #25

已合并
wx 2026-07-24 15:24:14 +08:00 将 1 次代码提交从 feat/5218-consumption-status 合并至 main
共有 9 个文件被更改,包括 752 次插入5 次删除
仅显示提交 6cbe22f40a 的更改 - 显示所有提交

查看文件

@ -41,3 +41,6 @@ jobs:
- name: Validate new changelog filenames - name: Validate new changelog filenames
run: npm run check:filenames -- --event "$GITHUB_EVENT_PATH" run: npm run check:filenames -- --event "$GITHUB_EVENT_PATH"
- name: Validate changelog frontmatter
run: npm run check:frontmatter -- --event "$GITHUB_EVENT_PATH"

查看文件

@ -0,0 +1,181 @@
# 后端 API Changelog 推送与交接指南
> 本文可直接发送给后端同事。适用于 `wx/HL` 的管理后台与小程序接口变更。
## 一、什么时候必须推送 changelog
以下变化需要 changelog
- Controller 路径、HTTP 方法或权限边界变化;
- DTO、VO、BO、Feign 请求或响应字段变化;
- 字段必填性、枚举、状态、金额、空值或兼容行为变化;
- 新增、修改、废弃或删除管理后台/小程序接口;
- 前端或其他调用方需要调整请求、解析或页面行为。
纯后端内部重构且外部契约完全不变时,可不创建;必须在工单中说明 `frontend_status: not_required` 的判断依据。
## 二、准备条件
1. 已有关联的合格 Gitea 工单。
2. 已确认目标端:
- 管理后台:`changelogs-v2/`
- 小程序端:`changelogs-v2-mp/`
3. 已确认变更类型:`新增接口``修改接口``删除接口`
4. `D:/work2/hl-ui` 保持只读,不在前端仓库创建配合工单。
5. changelog 仓库使用独立任务分支或 worktree,不把其他线程的未跟踪文件一起提交。
## 三、生成草稿
预览:
```powershell
hl changelog draft 5205 "车务首页汇总状态补全" `
--repo D:/work2/HL-v3-worktrees/5205 `
--base dev-v3 `
--track v3 `
--consumer admin `
--change-type 修改接口
```
确认目标路径和检测到的 Controller/DTO/VO/Feign 文件后写入:
```powershell
hl changelog draft 5205 "车务首页汇总状态补全" `
--repo D:/work2/HL-v3-worktrees/5205 `
--base dev-v3 `
--track v3 `
--consumer admin `
--change-type 修改接口 `
--write
```
`--write` 会自动获取 `changelog` 单写租约。手工创建或修改 changelog 时,应先执行:
```powershell
hl resource acquire changelog --ticket 5205 --ttl 1800
```
## 四、文件名
管理后台:
```text
changelogs-v2/YYYY-MM/DD_issue_业务标题-{新增接口|修改接口|删除接口}-管理后台.md
```
小程序:
```text
changelogs-v2-mp/YYYY-MM/DD_issue_业务标题-{新增接口|修改接口|删除接口}-小程序端.md
```
年月日必须使用提交时 `Asia/Shanghai` 的真实日期。状态不得写入文件名,不要增加“前端待处理”“已完成”等额外片段。
## 五、填写 v2 元数据
```yaml
---
schema: "hl-changelog/v2"
ticket: "5205"
title: "车务首页汇总状态补全"
consumer: "admin"
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"
---
```
规则:
- 自动草稿从 `backend_status: pending``gateway_status: pending` 开始。
- 后端实际部署完成后才能改为 `backend_status: deployed`
- 经网关验证后填写 `gateway_status: verified`;确实无需网关验证时使用 `not_required`
- 需要前端配合时初始化 `frontend_status: pending`
- 不需要前端修改时使用 `frontend_status: not_required`
- 后端不得代替前端填写 `implemented``released``verified`
## 六、正文必须写清
- 关联 Issue 和 PR;
- 变更接口清单;
- 请求与响应字段;
- 枚举、状态、空值、ID 和金额规则;
- 老数据和兼容行为;
- 前端/调用方需要采取的动作;
- 定向测试、网关验证和兼容性证据;
- 不影响范围。
页面展示、列表、汇总、看板、状态标签或颜色变化,还必须在后端工单中准备展示矩阵,明确数据来源、状态范围、空态、颜色和守恒规则。
## 七、本地校验
在 changelog 仓库执行:
```powershell
npm test
npm run check:filenames -- --base origin/main --head HEAD
npm run check:frontmatter -- --base origin/main --head HEAD
```
单文件还可以执行:
```powershell
hl changelog lint D:/path/changelog.md
```
发布前 lint 允许前端仍是 `pending`,但要求:
- `backend_status: deployed`
- `gateway_status` 不再是 `pending`
- 正文不存在 `TODO``待补充` 或模板占位符。
## 八、提交和推送
只暂存本任务文件,禁止使用会卷入其他线程文件的宽泛命令:
```powershell
git status --short
git add -- changelogs-v2/2026-07/24_5205_车务首页汇总状态补全-修改接口-管理后台.md
git diff --cached --check
git commit -m "docs: hand off fleet dashboard contract (#5205)"
git push -u origin <任务分支>
```
随后向 `main` 创建 PR。合并前再次检查上海日期;跨越上海零点且仍未合并时,按贡献规则重命名为当天日期。
不要直接提交:
- 其他线程的 changelog;
- `.tmp-*` 文件;
- token、密码、证书、真实隐私数据;
- `hl-ui` 代码。
## 九、回写后端任务
合并后在后端工单和任务台账记录:
- changelog 文件路径;
- changelog 提交或 PR;
- 当前 `frontend_status`
- 后端部署和网关验证证据。
```powershell
hl task update 5205 `
--changelog D:/path/changelog.md
```
后端工单可以按后端验收范围关闭;前端继续在同一 changelog 中推进消费状态。
手工持有租约时,完成后释放:
```powershell
hl resource release changelog --ticket 5205
```

查看文件

@ -1,3 +1,21 @@
---
schema: "hl-changelog/v2"
ticket: "{issue-no}"
title: "{一句话概括变化}"
consumer: "{admin|mp|internal|multiple}"
change_type: "{新增接口|修改接口|删除接口}"
backend_status: "pending"
gateway_status: "pending"
frontend_status: "{pending|not_required}"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: ""
updated_at: "YYYY-MM-DD"
base: "{dev|dev-v3}"
---
# {模块名}: {一句话概括变化} # {模块名}: {一句话概括变化}
> **存放目录**: > **存放目录**:

查看文件

@ -42,6 +42,42 @@ npm run check:filenames -- --base origin/main --head HEAD
生产 CLI 故意不提供 `--date` 或日期环境变量;测试只通过导出的纯函数注入 `Date`。规则失败返回退出码 `1`,Git/事件/参数等基础设施错误返回 `2` 生产 CLI 故意不提供 `--date` 或日期环境变量;测试只通过导出的纯函数注入 `Date`。规则失败返回退出码 `1`,Git/事件/参数等基础设施错误返回 `2`
## 前端消费状态
新增二期 changelog 必须使用 `hl-changelog/v2` YAML Front Matter。状态只写在元数据中,不写入文件名
```yaml
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
updated_at: "2026-07-24"
```
前端状态正常流转为:
```text
pending → claimed → implemented → released → verified
```
不需要前端修改时使用 `not_required`。字段一致性、必填证据和新增文档 frontmatter 由 `check:frontmatter` 校验。
完整职责和命令见:
- `FRONTEND_CONSUMPTION_STATUS_GUIDE.md`
- `BACKEND_CHANGELOG_DELIVERY_GUIDE.md`
本地校验:
```bash
npm test
npm run check:filenames -- --base origin/main --head HEAD
npm run check:frontmatter -- --base origin/main --head HEAD
```
## CI 与服务端阻断边界 ## CI 与服务端阻断边界
Gitea Actions 会在指向 `main` 的 PR 和 `main` 的 push 上运行回归测试与文件名检测。该 workflow 是检测器: Gitea Actions 会在指向 `main` 的 PR 和 `main` 的 push 上运行回归测试与文件名检测。该 workflow 是检测器:

查看文件

@ -0,0 +1,109 @@
# API Changelog 前端消费状态协作说明
## 可直接转发给前端的通知
API changelog 从 `hl-changelog/v2` 开始记录前端消费进度。后端交接时会填写:
```yaml
backend_status: "deployed"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
```
请前端在领取、实现、发布和页面验证时更新对应状态,并填写可追溯的前端 PR、提交或发布版本。
这不会把前端工作纳入后端工单验收,也不要求在 `hl-ui` 创建配合工单。它只用于区分:
- 后端接口是否已经部署并验证;
- 前端是否已经领取;
- 前端代码是否已经实现;
- 页面是否已经发布并验证。
只有 `frontend_status: "verified"` 才表示用户页面形成完整闭环。
## 状态流转
```text
pending → claimed → implemented → released → verified
```
不需要前端修改时:
```text
not_required
```
| 状态 | 含义 | 必填证据 |
|---|---|---|
| `not_required` | 不需要前端修改 | 不填写前端负责人、引用和版本 |
| `pending` | 等待前端领取 | 无 |
| `claimed` | 前端已领取 | `frontend_owner` |
| `implemented` | 前端代码已实现 | `frontend_owner``frontend_ref` |
| `released` | 已发布 | 再填写 `target_release` |
| `verified` | 页面已验证 | 再填写 `verified_at` |
跨级迁移会被自动校验拒绝。状态回退或改为/取消 `not_required` 时必须填写原因。
## 更新命令
领取:
```powershell
hl changelog transition 5205 D:/path/changelog.md claimed `
--owner frontend-team --write
```
实现:
```powershell
hl changelog transition 5205 D:/path/changelog.md implemented `
--owner frontend-team `
--frontend-ref "mmg/hl-ui@abc1234" `
--write
```
发布:
```powershell
hl changelog transition 5205 D:/path/changelog.md released `
--target-release "test-2026.07.24" `
--write
```
验证:
```powershell
hl changelog transition 5205 D:/path/changelog.md verified `
--verified-at "2026-07-24" `
--write
```
命令默认只预览;只有 `--write` 才修改文件。写入命令会自动获取 `changelog` 单写租约。
## 职责边界
后端负责:
- 完成后端测试、部署和网关验证;
- 初始化 v2 元数据;
- 需要前端时设置 `pending`,不需要时设置 `not_required`
- 不替前端填写 `implemented``released``verified`
前端负责:
- 领取时填写负责人;
- 实现后填写前端引用;
- 发布后填写目标版本或环境;
- 页面验证后填写验证日期。
QA 或产品可以协助更新 `verified_at`,但必须基于实际页面验证,不能只根据接口成功或代码已合并标记完成。
## 存量文档
- 新 changelog 全部使用 `hl-changelog/v2`
- `hl-changelog/v1` 继续可读和索引,不强制一次性迁移。
- 文件名带“前端待处理”不代表真实状态;需要继续流转时补充 v2 元数据。
- 不通过重命名表达消费状态,避免破坏文件名校验和历史链接。

查看文件

@ -3,8 +3,9 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "node --test tests/validate-changelog-filenames.test.mjs", "test": "node --test tests/validate-changelog-filenames.test.mjs tests/validate-changelog-frontmatter.test.mjs",
"check:filenames": "node scripts/validate-changelog-filenames.mjs" "check:filenames": "node scripts/validate-changelog-filenames.mjs",
"check:frontmatter": "node scripts/validate-changelog-frontmatter.mjs"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"

查看文件

@ -76,7 +76,7 @@ export function collectNewTargetPaths(records) {
.map(({ targetPath }) => targetPath); .map(({ targetPath }) => targetPath);
} }
function controlledRootForPath(inputPath) { export function controlledRootForPath(inputPath) {
const candidate = String(inputPath); const candidate = String(inputPath);
for (const root of CONTROLLED_ROOTS.keys()) { for (const root of CONTROLLED_ROOTS.keys()) {
if (candidate === root || candidate.startsWith(`${root}/`) || candidate.startsWith(`${root}\\`)) { if (candidate === root || candidate.startsWith(`${root}/`) || candidate.startsWith(`${root}\\`)) {
@ -190,7 +190,7 @@ export function runValidation(records, now = new Date()) {
}; };
} }
function parseArguments(argv) { export function parseArguments(argv) {
const options = {}; const options = {};
for (let index = 0; index < argv.length; index += 2) { for (let index = 0; index < argv.length; index += 2) {
const flag = argv[index]; const flag = argv[index];
@ -257,7 +257,7 @@ function gitDiff(revisions) {
}); });
} }
function diffFromOptions(options) { export function diffFromOptions(options) {
if (!options.event) { if (!options.event) {
const base = resolveCommitRef(options.base, 'base ref'); const base = resolveCommitRef(options.base, 'base ref');
const head = resolveCommitRef(options.head, 'head ref'); const head = resolveCommitRef(options.head, 'head ref');

查看文件

@ -0,0 +1,265 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
controlledRootForPath,
diffFromOptions,
parseArguments,
parseNameStatusZ,
} from './validate-changelog-filenames.mjs';
const FRONTEND_STATUSES = new Set([
'not_required',
'pending',
'claimed',
'implemented',
'released',
'verified',
]);
const BACKEND_STATUSES = new Set(['pending', 'tested', 'deployed']);
const GATEWAY_STATUSES = new Set(['pending', 'verified', 'not_required']);
const CONSUMERS = new Set(['admin', 'mp', 'internal', 'multiple']);
const CHANGE_TYPES = new Set(['新增接口', '修改接口', '删除接口']);
const REQUIRED_KEYS = [
'schema',
'ticket',
'title',
'consumer',
'change_type',
'backend_status',
'gateway_status',
'frontend_status',
'frontend_owner',
'frontend_ref',
'target_release',
'verified_at',
'updated_at',
'base',
];
function ruleError(code, file, message) {
return { code, path: file, message };
}
function isIsoDate(value) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? '');
if (!match) {
return false;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const parsed = new Date(Date.UTC(year, month - 1, day));
return parsed.getUTCFullYear() === year
&& parsed.getUTCMonth() === month - 1
&& parsed.getUTCDate() === day;
}
function isIsoDateOrTime(value) {
if (isIsoDate(value)) {
return true;
}
return /^\d{4}-\d{2}-\d{2}T/.test(value ?? '')
&& Number.isFinite(Date.parse(value));
}
export function parseFrontmatter(text) {
const value = String(text ?? '').replaceAll('\r\n', '\n');
if (!value.startsWith('---\n')) {
return { metadata: undefined, body: value };
}
const end = value.indexOf('\n---\n', 4);
if (end < 0) {
return { metadata: undefined, body: value };
}
const metadata = {};
for (const line of value.slice(4, end).split('\n')) {
const separator = line.indexOf(':');
if (separator < 0) {
continue;
}
const key = line.slice(0, separator).trim();
let fieldValue = line.slice(separator + 1).trim();
if (
(fieldValue.startsWith('"') && fieldValue.endsWith('"'))
|| (fieldValue.startsWith("'") && fieldValue.endsWith("'"))
) {
fieldValue = fieldValue.slice(1, -1);
}
metadata[key] = fieldValue;
}
return { metadata, body: value.slice(end + 5) };
}
export function validateFrontendState(metadata) {
const errors = [];
const status = metadata.frontend_status;
const owner = metadata.frontend_owner?.trim() ?? '';
const reference = metadata.frontend_ref?.trim() ?? '';
const release = metadata.target_release?.trim() ?? '';
const verifiedAt = metadata.verified_at?.trim() ?? '';
if (!FRONTEND_STATUSES.has(status)) {
return [`frontend_status 非法: ${status || '(空)'}`];
}
if (['claimed', 'implemented', 'released', 'verified'].includes(status) && !owner) {
errors.push(`${status} 必须填写 frontend_owner`);
}
if (['implemented', 'released', 'verified'].includes(status) && !reference) {
errors.push(`${status} 必须填写 frontend_ref`);
}
if (['released', 'verified'].includes(status) && !release) {
errors.push(`${status} 必须填写 target_release`);
}
if (status === 'verified' && !verifiedAt) {
errors.push('verified 必须填写 verified_at');
}
if (verifiedAt && !isIsoDateOrTime(verifiedAt)) {
errors.push('verified_at 必须是 ISO 日期或时间');
}
if (status === 'not_required' && [owner, reference, release, verifiedAt].some(Boolean)) {
errors.push('not_required 不得保留前端负责人、引用、版本或验证时间');
}
return errors;
}
export function validateFrontendTransition(current, target, reason = '') {
if (!FRONTEND_STATUSES.has(current) || !FRONTEND_STATUSES.has(target)) {
return ['frontend_status 非法'];
}
if (current === target) {
return [];
}
if (current === 'not_required' || target === 'not_required') {
return reason.trim() ? [] : ['涉及 not_required 的迁移必须填写原因'];
}
const order = ['pending', 'claimed', 'implemented', 'released', 'verified'];
const currentIndex = order.indexOf(current);
const targetIndex = order.indexOf(target);
if (targetIndex === currentIndex + 1) {
return [];
}
if (targetIndex < currentIndex) {
return reason.trim() ? [] : ['状态回退必须填写原因'];
}
return [`禁止跨级迁移: ${current} -> ${target}`];
}
export function validateV2Document(file, text, { requireV2 = false } = {}) {
const { metadata, body } = parseFrontmatter(text);
if (!metadata) {
return requireV2 ? [ruleError('E_FRONTMATTER', file, '新增 changelog 缺少 YAML Front Matter')] : [];
}
if (metadata.schema !== 'hl-changelog/v2') {
return requireV2
? [ruleError('E_SCHEMA', file, `新增 changelog 必须使用 hl-changelog/v2,当前为 ${metadata.schema || '(空)'}`)]
: [];
}
const errors = [];
for (const key of REQUIRED_KEYS) {
if (!(key in metadata)) {
errors.push(ruleError('E_REQUIRED', file, `frontmatter 缺少 ${key}`));
}
}
for (const key of ['ticket', 'title', 'consumer', 'change_type', 'backend_status', 'gateway_status', 'frontend_status', 'updated_at', 'base']) {
if (!metadata[key]?.trim()) {
errors.push(ruleError('E_REQUIRED', file, `${key} 不能为空`));
}
}
if (!CHANGE_TYPES.has(metadata.change_type)) {
errors.push(ruleError('E_CHANGE_TYPE', file, `change_type 非法: ${metadata.change_type || '(空)'}`));
}
if (!CONSUMERS.has(metadata.consumer)) {
errors.push(ruleError('E_CONSUMER', file, `consumer 非法: ${metadata.consumer || '(空)'}`));
}
if (!BACKEND_STATUSES.has(metadata.backend_status)) {
errors.push(ruleError('E_BACKEND_STATUS', file, `backend_status 非法: ${metadata.backend_status || '(空)'}`));
}
if (!GATEWAY_STATUSES.has(metadata.gateway_status)) {
errors.push(ruleError('E_GATEWAY_STATUS', file, `gateway_status 非法: ${metadata.gateway_status || '(空)'}`));
}
if (metadata.backend_status !== 'deployed') {
errors.push(ruleError('E_BACKEND_PENDING', file, '发布的 changelog 必须是 backend_status=deployed'));
}
if (metadata.gateway_status === 'pending') {
errors.push(ruleError('E_GATEWAY_PENDING', file, '发布的 changelog 不能保留 gateway_status=pending'));
}
for (const message of validateFrontendState(metadata)) {
errors.push(ruleError('E_FRONTEND_STATE', file, message));
}
if (metadata.consumer === 'internal' && metadata.frontend_status !== 'not_required') {
errors.push(ruleError('E_FRONTEND_STATE', file, 'internal consumer 必须使用 frontend_status=not_required'));
}
if (!isIsoDate(metadata.updated_at)) {
errors.push(ruleError('E_UPDATED_AT', file, 'updated_at 必须是真实的 YYYY-MM-DD 日期'));
}
const filename = path.posix.basename(file);
const issue = /^\d{2}_([1-9]\d*)_/.exec(filename)?.[1];
if (issue && metadata.ticket !== issue) {
errors.push(ruleError('E_TICKET_MISMATCH', file, `ticket=${metadata.ticket} 与文件名 Issue=${issue} 不一致`));
}
const filenameType = /-(新增接口|修改接口|删除接口)-(?:管理后台|小程序端)\.md$/.exec(filename)?.[1];
if (filenameType && metadata.change_type !== filenameType) {
errors.push(ruleError('E_TYPE_MISMATCH', file, `change_type=${metadata.change_type} 与文件名=${filenameType} 不一致`));
}
if (/\{[^{}\n]+\}|\bTODO\b|待补充/i.test(body)) {
errors.push(ruleError('E_PLACEHOLDER', file, '正文仍有 TODO、待补充或模板占位符'));
}
if (!body.includes('## 变更接口') || !body.includes('## 验证证据')) {
errors.push(ruleError('E_SECTIONS', file, '正文缺少“变更接口”或“验证证据”章节'));
}
return errors;
}
export function collectChangedDocuments(records) {
return records
.filter(({ status }) => status !== 'D')
.map((record) => ({
path: record.targetPath,
isNew: record.status === 'A' || /^C\d{1,3}$/.test(record.status) || /^R\d{1,3}$/.test(record.status),
}))
.filter(({ path: file }) => controlledRootForPath(file) && file.endsWith('.md'));
}
export function runFrontmatterValidation(records, root = process.cwd()) {
const documents = collectChangedDocuments(records);
const errors = [];
for (const document of documents) {
let text;
try {
text = readFileSync(path.join(root, ...document.path.split('/')), 'utf8');
} catch (error) {
errors.push(ruleError('E_READ', document.path, `无法读取文件: ${error.message}`));
continue;
}
errors.push(...validateV2Document(document.path, text, { requireV2: document.isNew }));
}
return { checkedCount: documents.length, errors };
}
export function main(argv = process.argv.slice(2)) {
try {
const options = parseArguments(argv);
const records = parseNameStatusZ(diffFromOptions(options));
const result = runFrontmatterValidation(records);
if (result.errors.length > 0) {
for (const error of result.errors) {
console.error(`[${error.code}] ${error.path}: ${error.message}`);
}
console.error(`FAIL: ${result.errors.length} frontmatter error(s) in ${result.checkedCount} changelog file(s).`);
return 1;
}
console.log(`PASS: validated frontmatter for ${result.checkedCount} changed changelog file(s).`);
return 0;
} catch (error) {
console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
return 2;
}
}
const isCli = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
if (isCli) {
process.exitCode = main();
}

查看文件

@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, 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',
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## 验证证据\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('accepts a complete v2 handoff with pending frontend consumption', () => {
assert.deepEqual(validateV2Document(FILE, document(), { requireV2: true }), []);
});
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'));
});