比较提交
8
次代码提交
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
bb53e6f542 | ||
|
|
a808064c73 | ||
|
|
5e517a4503 | ||
|
|
6cbe22f40a | ||
|
|
402e6cb45b | ||
|
|
8493c75ab4 | ||
|
|
59470f7b4c | ||
|
|
246e999244 |
@@ -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,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-*` 文件或任何凭据。
|
||||||
@@ -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 是检测器:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户