比较提交

...
作者 SHA1 备注 提交日期
wx bb53e6f542 docs: simplify backend changelog guide
changelog-filename-gate / validate (pull_request) Successful in 1s
2026-07-24 15:37:42 +08:00
Mimingguang a808064c73 chore(changelog): 标记前端已实现 #5216
changelog-filename-gate / validate (push) Successful in 1s
修改原因:hl-ui changelog loop 需要把前端消费进度回写到契约源,避免领取、实现状态与实际交付脱节。

修改内容:将 frontend_status 与已有 legacy frontend 同步为 implemented,记录负责人 hl-ui-codex,并关联 mmg/hl-ui@41f307090eccfdf3d06deabce8bc4f3d2be9a99a;发布和验收字段保持不变。

实际验证:回写器已校验目标文件、状态单调性、提交范围和 Front Matter 内容,提交只包含当前 changelog。

Frontend-Status: tools/mcp-api-sync/.changelog-repo/changelogs-v2/2026-07/24_5216_派车看板补充槽位接送路线与就绪摘要-修改接口-前端待处理-管理后台.md
2026-07-24 15:25:59 +08:00
wx 5e517a4503 Merge pull request 'feat: 增加 changelog 前端消费状态与交接指南 (#5218)' (#25) from feat/5218-consumption-status into main
changelog-filename-gate / validate (push) Successful in 2s
2026-07-24 15:24:14 +08:00
wx 6cbe22f40a feat: track frontend changelog consumption (#5218)
changelog-filename-gate / validate (pull_request) Successful in 2s
2026-07-24 15:22:10 +08:00
Mimingguang 402e6cb45b chore(changelog): 标记前端已领取 #5216
changelog-filename-gate / validate (push) Successful in 1s
修改原因:hl-ui changelog loop 需要把前端消费进度回写到契约源,避免领取、实现状态与实际交付脱节。

修改内容:将 frontend_status 与已有 legacy frontend 同步为 claimed,记录负责人 hl-ui-codex,实现引用保持为空;发布和验收字段保持不变。

实际验证:回写器已校验目标文件、状态单调性、提交范围和 Front Matter 内容,提交只包含当前 changelog。

Frontend-Status: tools/mcp-api-sync/.changelog-repo/changelogs-v2/2026-07/24_5216_派车看板补充槽位接送路线与就绪摘要-修改接口-前端待处理-管理后台.md
2026-07-24 15:17:30 +08:00
Mimingguang 8493c75ab4 chore(changelog): 标记前端已实现 #5215
修改原因:hl-ui changelog loop 需要把前端消费进度回写到契约源,避免领取、实现状态与实际交付脱节。

修改内容:将 frontend_status 与已有 legacy frontend 同步为 implemented,记录负责人 hl-ui-codex,并关联 mmg/hl-ui@a48846a3e35c06df3aef422e538d5cd198002c2c;发布和验收字段保持不变。

实际验证:回写器已校验目标文件、状态单调性、提交范围和 Front Matter 内容,提交只包含当前 changelog。

Frontend-Status: tools/mcp-api-sync/.changelog-repo/changelogs-v2/2026-07/24_5215_车务首页未完成状态独立汇总-修改接口-管理后台.md
2026-07-24 15:15:18 +08:00
Mimingguang 59470f7b4c chore(changelog): 标记前端已实现 #5211
修改原因:hl-ui changelog loop 需要把前端消费进度回写到契约源,避免领取、实现状态与实际交付脱节。

修改内容:将 frontend_status 与已有 legacy frontend 同步为 implemented,记录负责人 hl-ui-codex,并关联 mmg/hl-ui@06f9d4dce58ef64c3e5de96e44754c0793286d06;发布和验收字段保持不变。

实际验证:回写器已校验目标文件、状态单调性、提交范围和 Front Matter 内容,提交只包含当前 changelog。

Frontend-Status: tools/mcp-api-sync/.changelog-repo/changelogs-v2/2026-07/24_5211_派单通知预览补齐接送与行程数据-修改接口-管理后台.md
2026-07-24 15:15:17 +08:00
Mimingguang 246e999244 chore(changelog): 标记前端已实现 #5209
修改原因:hl-ui changelog loop 需要把前端消费进度回写到契约源,避免领取、实现状态与实际交付脱节。

修改内容:将 frontend_status 与已有 legacy frontend 同步为 implemented,记录负责人 hl-ui-codex,并关联 mmg/hl-ui@4424375ef9180e69f22a4f5f6b0b80c9ec2062b7;发布和验收字段保持不变。

实际验证:回写器已校验目标文件、状态单调性、提交范围和 Front Matter 内容,提交只包含当前 changelog。

Frontend-Status: tools/mcp-api-sync/.changelog-repo/changelogs-v2/2026-07/24_5209_出行人省份与分批大交通关联-修改接口-管理后台.md
2026-07-24 15:15:16 +08:00
共修改 13 个文件,包含 667 行新增和 8 行删除
@@ -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"
+75
查看文件
@@ -0,0 +1,75 @@
# 后端 API Changelog 推送说明
接口发生新增、修改或删除时,在 `hl-api-changelog` 仓库提交一份 changelog。
## 1. 放在哪里
- 管理后台:`changelogs-v2/YYYY-MM/`
- 小程序:`changelogs-v2-mp/YYYY-MM/`
文件名:
```text
DD_issue_业务标题-{新增接口|修改接口|删除接口}-{管理后台|小程序端}.md
```
例如:
```text
changelogs-v2/2026-07/24_5205_车务首页汇总状态补全-修改接口-管理后台.md
```
日期使用提交时的上海日期;不要把“前端待处理”“已完成”等状态写进文件名。
## 2. 写什么
可以复制仓库根目录的 `CHANGELOG_TEMPLATE.md`,至少写清:
- 关联的 Issue 和后端 PR;
- 接口路径和 HTTP 方法;
- 新增、修改或删除的请求/响应字段;
- 字段必填性、枚举、状态、空值、金额和兼容规则;
- 前端需要做什么;
- 后端测试、部署和网关验证结果。
元数据中:
```yaml
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
```
- 需要前端修改:`frontend_status: "pending"`
- 不需要前端修改:`frontend_status: "not_required"`
- 后端不要代替前端填写 `implemented`、`released` 或 `verified`
## 3. 校验
在 `hl-api-changelog` 仓库执行:
```powershell
npm test
npm run check:filenames -- --base origin/main --head HEAD
npm run check:frontmatter -- --base origin/main --head HEAD
```
确保正文没有 `TODO`、`待补充` 或模板占位符。
## 4. 提交和推送
只暂存本次 changelog 文件:
```powershell
git status --short
git add -- changelogs-v2/2026-07/24_5205_车务首页汇总状态补全-修改接口-管理后台.md
git diff --cached --check
git commit -m "docs: hand off API contract (#5205)"
git push -u origin <任务分支>
```
然后向 `main` 创建 PR。不要提交其他任务的 changelog、`.tmp-*` 文件或任何凭据。
+18
查看文件
@@ -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}"
---
# {模块名}: {一句话概括变化} # {模块名}: {一句话概括变化}
> **存放目录**: > **存放目录**:
+36
查看文件
@@ -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 是检测器:
+109
查看文件
@@ -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 元数据。
- 不通过重命名表达消费状态,避免破坏文件名校验和历史链接。
@@ -5,7 +5,11 @@ title: "出行人省份与分批大交通关联"
consumer: "admin" consumer: "admin"
backend: "verified" backend: "verified"
gateway: "verified" gateway: "verified"
frontend: "pending" frontend: "implemented"
frontend_status: "implemented"
frontend_owner: "hl-ui-codex"
frontend_ref: "mmg/hl-ui@4424375ef9180e69f22a4f5f6b0b80c9ec2062b7"
updated_at: "2026-07-24T07:15:16.554Z"
base: "dev-v3" base: "dev-v3"
generated: "2026-07-24T11:58:00+08:00" generated: "2026-07-24T11:58:00+08:00"
--- ---
@@ -1,3 +1,9 @@
---
frontend_status: "implemented"
frontend_owner: "hl-ui-codex"
frontend_ref: "mmg/hl-ui@06f9d4dce58ef64c3e5de96e44754c0793286d06"
updated_at: "2026-07-24T07:15:17.169Z"
---
# 车务:派单通知预览补齐接送与行程数据 # 车务:派单通知预览补齐接送与行程数据
> **服务**: hl-fleet-service(8087/8187) > **服务**: hl-fleet-service(8087/8187)
@@ -5,7 +5,11 @@ title: "车务首页未完成状态独立汇总"
consumer: "admin" consumer: "admin"
backend: "verified" backend: "verified"
gateway: "verified" gateway: "verified"
frontend: "pending" frontend: "implemented"
frontend_status: "implemented"
frontend_owner: "hl-ui-codex"
frontend_ref: "mmg/hl-ui@a48846a3e35c06df3aef422e538d5cd198002c2c"
updated_at: "2026-07-24T07:15:17.828Z"
base: "dev-v3" base: "dev-v3"
generated: "2026-07-24T14:25:00+08:00" generated: "2026-07-24T14:25:00+08:00"
--- ---
@@ -5,7 +5,11 @@ title: "派车看板补充槽位接送路线与就绪摘要"
consumer: "admin" consumer: "admin"
backend: "verified" backend: "verified"
gateway: "verified" gateway: "verified"
frontend: "pending" frontend: "implemented"
frontend_status: "implemented"
frontend_owner: "hl-ui-codex"
frontend_ref: "mmg/hl-ui@41f307090eccfdf3d06deabce8bc4f3d2be9a99a"
updated_at: "2026-07-24T07:25:59.029Z"
base: "dev-v3" base: "dev-v3"
generated: "2026-07-24T14:24:00+08:00" generated: "2026-07-24T14:24:00+08:00"
--- ---
+3 -2
查看文件
@@ -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"
+3 -3
查看文件
@@ -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');
+265
查看文件
@@ -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'));
});