From 6cbe22f40a8eb95930669b8566f1dcc40b39d9b5 Mon Sep 17 00:00:00 2001 From: wx <2636507191@qq.com> Date: Fri, 24 Jul 2026 15:22:10 +0800 Subject: [PATCH] feat: track frontend changelog consumption (#5218) --- .../validate-changelog-filenames.yml | 3 + BACKEND_CHANGELOG_DELIVERY_GUIDE.md | 181 ++++++++++++ CHANGELOG_TEMPLATE.md | 18 ++ CONTRIBUTING.md | 36 +++ FRONTEND_CONSUMPTION_STATUS_GUIDE.md | 109 +++++++ package.json | 5 +- scripts/validate-changelog-filenames.mjs | 6 +- scripts/validate-changelog-frontmatter.mjs | 265 ++++++++++++++++++ tests/validate-changelog-frontmatter.test.mjs | 134 +++++++++ 9 files changed, 752 insertions(+), 5 deletions(-) create mode 100644 BACKEND_CHANGELOG_DELIVERY_GUIDE.md create mode 100644 FRONTEND_CONSUMPTION_STATUS_GUIDE.md create mode 100644 scripts/validate-changelog-frontmatter.mjs create mode 100644 tests/validate-changelog-frontmatter.test.mjs diff --git a/.gitea/workflows/validate-changelog-filenames.yml b/.gitea/workflows/validate-changelog-filenames.yml index 8767b3e..9965474 100644 --- a/.gitea/workflows/validate-changelog-filenames.yml +++ b/.gitea/workflows/validate-changelog-filenames.yml @@ -41,3 +41,6 @@ jobs: - name: Validate new changelog filenames run: npm run check:filenames -- --event "$GITHUB_EVENT_PATH" + + - name: Validate changelog frontmatter + run: npm run check:frontmatter -- --event "$GITHUB_EVENT_PATH" diff --git a/BACKEND_CHANGELOG_DELIVERY_GUIDE.md b/BACKEND_CHANGELOG_DELIVERY_GUIDE.md new file mode 100644 index 0000000..d202040 --- /dev/null +++ b/BACKEND_CHANGELOG_DELIVERY_GUIDE.md @@ -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 +``` diff --git a/CHANGELOG_TEMPLATE.md b/CHANGELOG_TEMPLATE.md index afe6c6c..ab4d9f3 100644 --- a/CHANGELOG_TEMPLATE.md +++ b/CHANGELOG_TEMPLATE.md @@ -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}" +--- + # {模块名}: {一句话概括变化} > **存放目录**: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2fa461f..f32a8a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,42 @@ npm run check:filenames -- --base origin/main --head HEAD 生产 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 与服务端阻断边界 Gitea Actions 会在指向 `main` 的 PR 和 `main` 的 push 上运行回归测试与文件名检测。该 workflow 是检测器: diff --git a/FRONTEND_CONSUMPTION_STATUS_GUIDE.md b/FRONTEND_CONSUMPTION_STATUS_GUIDE.md new file mode 100644 index 0000000..19ef852 --- /dev/null +++ b/FRONTEND_CONSUMPTION_STATUS_GUIDE.md @@ -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 元数据。 +- 不通过重命名表达消费状态,避免破坏文件名校验和历史链接。 diff --git a/package.json b/package.json index c8732ed..c198b01 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,9 @@ "private": true, "type": "module", "scripts": { - "test": "node --test tests/validate-changelog-filenames.test.mjs", - "check:filenames": "node scripts/validate-changelog-filenames.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:frontmatter": "node scripts/validate-changelog-frontmatter.mjs" }, "engines": { "node": ">=20" diff --git a/scripts/validate-changelog-filenames.mjs b/scripts/validate-changelog-filenames.mjs index 1b9b701..068632c 100644 --- a/scripts/validate-changelog-filenames.mjs +++ b/scripts/validate-changelog-filenames.mjs @@ -76,7 +76,7 @@ export function collectNewTargetPaths(records) { .map(({ targetPath }) => targetPath); } -function controlledRootForPath(inputPath) { +export function controlledRootForPath(inputPath) { const candidate = String(inputPath); for (const root of CONTROLLED_ROOTS.keys()) { 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 = {}; for (let index = 0; index < argv.length; index += 2) { const flag = argv[index]; @@ -257,7 +257,7 @@ function gitDiff(revisions) { }); } -function diffFromOptions(options) { +export function diffFromOptions(options) { if (!options.event) { const base = resolveCommitRef(options.base, 'base ref'); const head = resolveCommitRef(options.head, 'head ref'); diff --git a/scripts/validate-changelog-frontmatter.mjs b/scripts/validate-changelog-frontmatter.mjs new file mode 100644 index 0000000..7c03af2 --- /dev/null +++ b/scripts/validate-changelog-frontmatter.mjs @@ -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(); +} diff --git a/tests/validate-changelog-frontmatter.test.mjs b/tests/validate-changelog-frontmatter.test.mjs new file mode 100644 index 0000000..23f7178 --- /dev/null +++ b/tests/validate-changelog-frontmatter.test.mjs @@ -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')); +}); -- 2.43.0