fix: restore stable changelog path aliases for #5252 #39

已合并
wx 2026-07-26 15:06:59 +08:00 将 1 次代码提交从 fix/5252-changelog-path-alias合并至 main
共有 11 个文件被更改,包括 675 次插入15 次删除
仅显示提交 5c7fa5dbe6 的更改 - 显示所有提交

查看文件

@ -44,3 +44,6 @@ jobs:
- name: Validate changelog frontmatter
run: npm run check:frontmatter -- --event "$GITHUB_EVENT_PATH"
- name: Validate changelog path aliases
run: npm run check:path-aliases

查看文件

@ -30,7 +30,9 @@ changelogs-v2-mp/YYYY-MM/DD_issue_业务标题-{新增接口|修改接口|删除
检测器读取 `git diff --name-status -z --find-renames` 的结果,只校验本次 diff 新出现的目标路径:
- `A`(新增)、`C`(复制)和 `R`(重命名)的目标路径必须通过规则。
- `M`(修改历史文件)和 `D`(删除)豁免,不会因存量错误命名阻断。
- `M`(修改历史文件)豁免,不会因存量错误命名阻断。
- 已发布文件的 `D`(删除)和 `R`(重命名)默认以 `E_PATH_STABILITY` 阻断。确需迁移时,必须在
`changelog-path-aliases.json` 登记旧路径到 canonical 的精确关系,并保留可读取的兼容入口。
- 重命名到受控目录时,新目标路径必须使用校验当天的上海日期。
本地校验:
@ -38,6 +40,7 @@ changelogs-v2-mp/YYYY-MM/DD_issue_业务标题-{新增接口|修改接口|删除
```bash
npm test
npm run check:filenames -- --base origin/main --head HEAD
npm run check:path-aliases
```
生产 CLI 故意不提供 `--date` 或日期环境变量;测试只通过导出的纯函数注入 `Date`。规则失败返回退出码 `1`,Git/事件/参数等基础设施错误返回 `2`
@ -70,12 +73,20 @@ pending → claimed → implemented → released → verified
- `FRONTEND_CONSUMPTION_STATUS_GUIDE.md`
- `BACKEND_CHANGELOG_DELIVERY_GUIDE.md`
已下发路径是消费契约的一部分,不通过重命名表达状态。历史路径已发生迁移时:
- `changelog-path-aliases.json` 是机器可识别的唯一映射源;
- alias 文件必须保留完整 `hl-changelog/v2` frontmatter,并用 `canonical_path` 指向 canonical;
- 前端状态更新使用 `npm run changelog:transition -- <path> <status> ... --write`,命令会同时更新
canonical 与全部 alias;对同一状态和证据重复执行不会产生文件变更。
本地校验:
```bash
npm test
npm run check:filenames -- --base origin/main --head HEAD
npm run check:frontmatter -- --base origin/main --head HEAD
npm run check:path-aliases
```
## CI 与服务端阻断边界

查看文件

@ -49,6 +49,30 @@ not_required
## 更新命令
### 存在历史路径 alias 的文档
消费线程已经记录的路径不得因文件改名失效。先解析路径:
```powershell
npm run changelog:resolve -- "changelogs-v2/2026-07/旧路径.md"
```
状态回写统一使用 alias-aware 命令;传旧路径或 canonical 均会同时更新整组文件:
```powershell
npm run changelog:transition -- `
"changelogs-v2/2026-07/旧路径.md" implemented `
--owner frontend-team `
--frontend-ref "mmg/hl-ui@abc1234" `
--write
```
相同状态和证据可以重复执行,第二次不会产生文件变更。alias 关系集中记录在
`changelog-path-aliases.json`,并由 `npm run check:path-aliases` 校验文件存在性、ticket、
canonical 指向及前端状态一致性。
### 无 alias 的文档
领取:
```powershell
@ -107,3 +131,4 @@ QA 或产品可以协助更新 `verified_at`,但必须基于实际页面验证
- `hl-changelog/v1` 继续可读和索引,不强制一次性迁移。
- 文件名带“前端待处理”不代表真实状态;需要继续流转时补充 v2 元数据。
- 不通过重命名表达消费状态,避免破坏文件名校验和历史链接。
- 已被消费的路径如确需规范化,必须先登记 alias、保留兼容入口,并使用 alias-aware 命令同步状态。

11
changelog-path-aliases.json 普通文件
查看文件

@ -0,0 +1,11 @@
{
"schema": "hl-changelog-path-aliases/v1",
"aliases": [
{
"ticket": "5216",
"alias": "changelogs-v2/2026-07/24_5216_派车看板补充槽位接送路线与就绪摘要-修改接口-前端待处理-管理后台.md",
"canonical": "changelogs-v2/2026-07/24_5216_派车看板补充槽位接送路线与就绪摘要-修改接口-管理后台.md",
"reason": "兼容前端消费线程在文件重命名前持久化的稳定路径"
}
]
}

查看文件

@ -0,0 +1,33 @@
---
schema: "hl-changelog/v2"
ticket: "5216"
title: "派车看板补充槽位接送路线与就绪摘要"
consumer: "admin"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "implemented"
frontend_owner: "hl-ui-codex"
frontend_ref: "mmg/hl-ui@cd493f83a7881401552494fc5a90fbb87395131b"
target_release: "hl-ui/v2.1"
verified_at: ""
canonical_path: "changelogs-v2/2026-07/24_5216_派车看板补充槽位接送路线与就绪摘要-修改接口-管理后台.md"
status_note: "兼容 #5216 在文件重命名前已被前端消费线程持久化的稳定路径;状态更新必须通过 alias-aware transition 同步 canonical。"
updated_at: "2026-07-26"
base: "dev-v3"
---
# 【路径兼容·管理后台】派车看板补充槽位接送路线与就绪摘要
本文件是 #5216 已下发旧路径的兼容入口。完整接口说明以
`changelogs-v2/2026-07/24_5216_派车看板补充槽位接送路线与就绪摘要-修改接口-管理后台.md`
为 canonical;不得删除或再次重命名本文件。
## 变更接口
- 不新增或修改业务接口;本文件仅恢复前端状态回写所需的稳定路径。
## 验证证据
- `changelog-path-aliases.json` 记录旧路径与 canonical 的机器可识别关系。
- alias 校验与状态同步测试覆盖读取、同步写入和重复写入。

查看文件

@ -11,8 +11,9 @@ frontend_owner: "hl-ui-codex"
frontend_ref: "mmg/hl-ui@cd493f83a7881401552494fc5a90fbb87395131b"
target_release: "hl-ui/v2.1"
verified_at: ""
path_aliases: "changelogs-v2/2026-07/24_5216_派车看板补充槽位接送路线与就绪摘要-修改接口-前端待处理-管理后台.md"
status_note: "后端与网关已验证;前端 implemented 状态由前端消费线程维护,本次仅迁移 schema。"
updated_at: "2026-07-25T03:29:38.101Z"
updated_at: "2026-07-26"
base: "dev-v3"
generated: "2026-07-24T14:24:00+08:00"
---

查看文件

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

查看文件

@ -0,0 +1,361 @@
#!/usr/bin/env node
import {
existsSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
parseFrontmatter,
validateFrontendState,
validateFrontendTransition,
} from './validate-changelog-frontmatter.mjs';
export const PATH_ALIAS_MANIFEST = 'changelog-path-aliases.json';
export const PATH_ALIAS_SCHEMA = 'hl-changelog-path-aliases/v1';
const CONTROLLED_ROOTS = new Set(['changelogs-v2', 'changelogs-v2-mp']);
const FRONTEND_FIELDS = [
'frontend_status',
'frontend_owner',
'frontend_ref',
'target_release',
'verified_at',
];
function aliasError(code, inputPath, message) {
return { code, path: inputPath, message };
}
export function normalizeRepoPath(value, label = 'path') {
if (typeof value !== 'string' || value.length === 0 || /[\0\r\n]/.test(value)) {
throw new Error(`${label} 必须是非空且不含控制字符的 Git 路径`);
}
if (value.includes('\\') || path.posix.isAbsolute(value) || /^[A-Za-z]:/.test(value)) {
throw new Error(`${label} 必须使用仓库内 POSIX 相对路径`);
}
const normalized = path.posix.normalize(value);
if (normalized !== value || normalized === '.' || normalized.startsWith('../')) {
throw new Error(`${label} 不能包含 .、.. 或非规范路径片段`);
}
const [root] = normalized.split('/');
if (!CONTROLLED_ROOTS.has(root) || !normalized.endsWith('.md')) {
throw new Error(`${label} 必须指向受控 changelog Markdown`);
}
return normalized;
}
export function parsePathAliasManifest(text) {
let value;
try {
value = JSON.parse(String(text));
} catch (error) {
throw new Error(`alias manifest 不是合法 JSON: ${error.message}`);
}
if (value?.schema !== PATH_ALIAS_SCHEMA || !Array.isArray(value.aliases)) {
throw new Error(`alias manifest 必须使用 ${PATH_ALIAS_SCHEMA} 且包含 aliases 数组`);
}
const seenAliases = new Set();
const aliases = value.aliases.map((entry, index) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
throw new Error(`aliases[${index}] 必须是对象`);
}
const ticket = String(entry.ticket ?? '');
if (!/^[1-9]\d*$/.test(ticket)) {
throw new Error(`aliases[${index}].ticket 必须是正整数`);
}
const alias = normalizeRepoPath(entry.alias, `aliases[${index}].alias`);
const canonical = normalizeRepoPath(entry.canonical, `aliases[${index}].canonical`);
if (alias === canonical) {
throw new Error(`aliases[${index}] 的 alias 与 canonical 不能相同`);
}
if (seenAliases.has(alias)) {
throw new Error(`alias 重复: ${alias}`);
}
if (typeof entry.reason !== 'string' || entry.reason.trim() === '') {
throw new Error(`aliases[${index}].reason 不能为空`);
}
seenAliases.add(alias);
return { ticket, alias, canonical, reason: entry.reason.trim() };
});
for (const entry of aliases) {
if (seenAliases.has(entry.canonical)) {
throw new Error(`暂不允许 alias 链或环: ${entry.alias} -> ${entry.canonical}`);
}
}
return { schema: PATH_ALIAS_SCHEMA, aliases };
}
export function loadPathAliasManifest(root = process.cwd()) {
const manifestPath = path.join(root, PATH_ALIAS_MANIFEST);
if (!existsSync(manifestPath)) {
return { schema: PATH_ALIAS_SCHEMA, aliases: [] };
}
return parsePathAliasManifest(readFileSync(manifestPath, 'utf8'));
}
export function resolveChangelogPath(inputPath, manifest) {
const normalized = normalizeRepoPath(inputPath);
return manifest.aliases.find(({ alias }) => alias === normalized)?.canonical ?? normalized;
}
function absoluteRepoPath(root, repoPath) {
return path.join(root, ...repoPath.split('/'));
}
function readDocument(root, repoPath) {
const absolute = absoluteRepoPath(root, repoPath);
if (!existsSync(absolute)) {
return { error: aliasError('E_ALIAS_MISSING', repoPath, '文件不存在') };
}
const content = readFileSync(absolute, 'utf8');
const { metadata } = parseFrontmatter(content);
if (!metadata) {
return { error: aliasError('E_ALIAS_FRONTMATTER', repoPath, '缺少 YAML Front Matter') };
}
return { absolute, content, metadata };
}
export function validatePathAliases(root = process.cwd(), manifest = loadPathAliasManifest(root)) {
const errors = [];
for (const entry of manifest.aliases) {
const aliasDocument = readDocument(root, entry.alias);
const canonicalDocument = readDocument(root, entry.canonical);
if (aliasDocument.error) {
errors.push(aliasDocument.error);
continue;
}
if (canonicalDocument.error) {
errors.push(canonicalDocument.error);
continue;
}
for (const [repoPath, metadata] of [
[entry.alias, aliasDocument.metadata],
[entry.canonical, canonicalDocument.metadata],
]) {
if (metadata.schema !== 'hl-changelog/v2') {
errors.push(aliasError('E_ALIAS_SCHEMA', repoPath, '兼容组文档必须使用 hl-changelog/v2'));
}
if (metadata.ticket !== entry.ticket) {
errors.push(aliasError(
'E_ALIAS_TICKET',
repoPath,
`ticket=${metadata.ticket || '(空)'},manifest ticket=${entry.ticket}`,
));
}
}
if (aliasDocument.metadata.canonical_path !== entry.canonical) {
errors.push(aliasError(
'E_ALIAS_TARGET',
entry.alias,
`canonical_path 必须等于 ${entry.canonical}`,
));
}
for (const field of FRONTEND_FIELDS) {
if ((aliasDocument.metadata[field] ?? '') !== (canonicalDocument.metadata[field] ?? '')) {
errors.push(aliasError(
'E_ALIAS_STATE',
entry.alias,
`${field} 与 canonical 不一致`,
));
}
}
}
return { checkedCount: manifest.aliases.length, errors };
}
function shanghaiDate(now = new Date()) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(now);
const values = Object.fromEntries(parts.map(({ type, value }) => [type, value]));
return `${values.year}-${values.month}-${values.day}`;
}
function replaceFrontmatterFields(content, fields) {
const normalized = String(content).replaceAll('\r\n', '\n');
const end = normalized.indexOf('\n---\n', 4);
if (!normalized.startsWith('---\n') || end < 0) {
throw new Error('文档缺少可写的 YAML Front Matter');
}
const lines = normalized.slice(4, end).split('\n');
const remaining = new Set(Object.keys(fields));
const updated = lines.map((line) => {
const separator = line.indexOf(':');
if (separator < 0) {
return line;
}
const key = line.slice(0, separator).trim();
if (!remaining.has(key)) {
return line;
}
remaining.delete(key);
return `${key}: ${JSON.stringify(String(fields[key] ?? ''))}`;
});
if (remaining.size > 0) {
throw new Error(`frontmatter 缺少待更新字段: ${[...remaining].join(', ')}`);
}
return `---\n${updated.join('\n')}\n---\n${normalized.slice(end + 5)}`;
}
export function transitionAliasedFrontendState(
root,
inputPath,
{
status,
owner,
frontendRef,
targetRelease,
verifiedAt,
reason = '',
},
{ now = new Date(), write = false } = {},
) {
const manifest = loadPathAliasManifest(root);
const before = validatePathAliases(root, manifest);
if (before.errors.length > 0) {
throw new Error(`alias manifest 校验失败: ${before.errors.map(({ message }) => message).join(';')}`);
}
const canonical = resolveChangelogPath(inputPath, manifest);
const canonicalDocument = readDocument(root, canonical);
if (canonicalDocument.error) {
throw new Error(`${canonicalDocument.error.path}: ${canonicalDocument.error.message}`);
}
const current = canonicalDocument.metadata.frontend_status;
const transitionErrors = validateFrontendTransition(current, status, reason);
if (transitionErrors.length > 0) {
throw new Error(transitionErrors.join(';'));
}
const fields = {
frontend_status: status,
frontend_owner: owner ?? canonicalDocument.metadata.frontend_owner ?? '',
frontend_ref: frontendRef ?? canonicalDocument.metadata.frontend_ref ?? '',
target_release: targetRelease ?? canonicalDocument.metadata.target_release ?? '',
verified_at: verifiedAt ?? canonicalDocument.metadata.verified_at ?? '',
updated_at: shanghaiDate(now),
};
const stateErrors = validateFrontendState(fields);
if (stateErrors.length > 0) {
throw new Error(stateErrors.join(';'));
}
const group = [
canonical,
...manifest.aliases
.filter((entry) => entry.canonical === canonical)
.map((entry) => entry.alias),
];
const outputs = group.map((repoPath) => {
const document = readDocument(root, repoPath);
if (document.error) {
throw new Error(`${document.error.path}: ${document.error.message}`);
}
const content = replaceFrontmatterFields(document.content, fields);
return {
repoPath,
absolute: document.absolute,
content,
changed: content !== document.content.replaceAll('\r\n', '\n'),
};
});
if (write) {
for (const output of outputs.filter(({ changed }) => changed)) {
writeFileSync(output.absolute, output.content, 'utf8');
}
const after = validatePathAliases(root, manifest);
if (after.errors.length > 0) {
throw new Error(`写入后 alias 状态不一致: ${after.errors.map(({ message }) => message).join(';')}`);
}
}
return {
canonical,
paths: group,
changedPaths: outputs.filter(({ changed }) => changed).map(({ repoPath }) => repoPath),
metadata: fields,
write,
};
}
function parseCli(argv) {
if (argv.length === 0 || (argv.length === 1 && argv[0] === '--check')) {
return { command: 'check' };
}
if (argv[0] === '--resolve' && argv.length === 2) {
return { command: 'resolve', path: argv[1] };
}
if (argv[0] !== '--transition') {
throw new Error('用法: --check | --resolve <path> | --transition <path> <status> [options]');
}
if (argv.length < 3) {
throw new Error('--transition 需要 path 和 status');
}
const options = { command: 'transition', path: argv[1], status: argv[2], write: false };
const mapping = new Map([
['--owner', 'owner'],
['--frontend-ref', 'frontendRef'],
['--target-release', 'targetRelease'],
['--verified-at', 'verifiedAt'],
['--reason', 'reason'],
]);
for (let index = 3; index < argv.length; index += 1) {
const flag = argv[index];
if (flag === '--write') {
options.write = true;
continue;
}
const key = mapping.get(flag);
const value = argv[index + 1];
if (!key || value === undefined) {
throw new Error(`不支持或不完整的参数: ${flag}`);
}
options[key] = value;
index += 1;
}
return options;
}
export function main(argv = process.argv.slice(2), root = process.cwd()) {
try {
const options = parseCli(argv);
const manifest = loadPathAliasManifest(root);
if (options.command === 'check') {
const result = validatePathAliases(root, manifest);
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} path alias error(s).`);
return 1;
}
console.log(`PASS: validated ${result.checkedCount} changelog path alias(es).`);
return 0;
}
if (options.command === 'resolve') {
console.log(resolveChangelogPath(options.path, manifest));
return 0;
}
const result = transitionAliasedFrontendState(root, options.path, options, {
write: options.write,
});
console.log(JSON.stringify(result, null, 2));
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();
}

查看文件

@ -9,6 +9,7 @@ const CONTROLLED_ROOTS = new Map([
['changelogs-v2-mp', '小程序端'],
]);
const CHANGE_TYPES = new Set(['新增接口', '修改接口', '删除接口']);
const PATH_ALIAS_MANIFEST = 'changelog-path-aliases.json';
function ruleError(code, path, message, expected) {
return { code, path, message, expected };
@ -76,6 +77,40 @@ export function collectNewTargetPaths(records) {
.map(({ targetPath }) => targetPath);
}
export function pathAliasMapFromManifest(value) {
if (value?.schema !== 'hl-changelog-path-aliases/v1' || !Array.isArray(value.aliases)) {
throw new Error('changelog-path-aliases.json schema 或 aliases 非法');
}
const aliases = new Map();
for (const [index, entry] of value.aliases.entries()) {
if (
!entry
|| typeof entry.alias !== 'string'
|| typeof entry.canonical !== 'string'
|| entry.alias.length === 0
|| entry.canonical.length === 0
) {
throw new Error(`changelog-path-aliases.json aliases[${index}] 缺少 alias/canonical`);
}
if (aliases.has(entry.alias)) {
throw new Error(`changelog-path-aliases.json alias 重复: ${entry.alias}`);
}
aliases.set(entry.alias, entry.canonical);
}
return aliases;
}
export function loadPathAliasMap(root = process.cwd()) {
try {
return pathAliasMapFromManifest(JSON.parse(readFileSync(`${root}/${PATH_ALIAS_MANIFEST}`, 'utf8')));
} catch (error) {
if (error?.code === 'ENOENT') {
return new Map();
}
throw error;
}
}
export function controlledRootForPath(inputPath) {
const candidate = String(inputPath);
for (const root of CONTROLLED_ROOTS.keys()) {
@ -178,10 +213,32 @@ export function validateChangelogPath(inputPath, now = new Date()) {
return errors;
}
export function runValidation(records, now = new Date()) {
export function runValidation(records, now = new Date(), { pathAliases = new Map() } = {}) {
const targetPaths = collectNewTargetPaths(records);
const controlledPaths = targetPaths.filter((targetPath) => controlledRootForPath(targetPath));
const errors = controlledPaths.flatMap((targetPath) => validateChangelogPath(targetPath, now));
const errors = controlledPaths.flatMap((targetPath) => (
pathAliases.has(targetPath) ? [] : validateChangelogPath(targetPath, now)
));
for (const record of records) {
const sourcePath = /^R\d{1,3}$/.test(record.status)
? record.sourcePath
: record.status === 'D' ? record.targetPath : undefined;
if (!sourcePath || !controlledRootForPath(sourcePath) || !sourcePath.endsWith('.md')) {
continue;
}
const expectedTarget = pathAliases.get(sourcePath);
const actualTarget = /^R\d{1,3}$/.test(record.status) ? record.targetPath : undefined;
if (!expectedTarget || (actualTarget && expectedTarget !== actualTarget)) {
errors.push(ruleError(
'E_PATH_STABILITY',
sourcePath,
'已发布 changelog 不得在缺少精确 alias 关系时删除或重命名',
actualTarget
? `${PATH_ALIAS_MANIFEST} 登记 ${sourcePath} -> ${actualTarget} 并保留兼容入口`
: `${PATH_ALIAS_MANIFEST} 登记迁移关系并保留兼容入口`,
));
}
}
return {
targetCount: targetPaths.length,
checkedCount: controlledPaths.length,
@ -294,7 +351,9 @@ export function main(argv = process.argv.slice(2)) {
try {
const options = parseArguments(argv);
const records = parseNameStatusZ(diffFromOptions(options));
const result = runValidation(records, new Date());
const result = runValidation(records, new Date(), {
pathAliases: loadPathAliasMap(),
});
if (result.errors.length > 0) {
for (const error of result.errors) {
console.error(`[${error.code}] ${error.path}: ${error.message}; expected ${error.expected}`);

查看文件

@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
loadPathAliasManifest,
parsePathAliasManifest,
resolveChangelogPath,
transitionAliasedFrontendState,
validatePathAliases,
} from '../scripts/changelog-path-aliases.mjs';
import { parseFrontmatter } from '../scripts/validate-changelog-frontmatter.mjs';
const OLD_5216 = 'changelogs-v2/2026-07/24_5216_派车看板补充槽位接送路线与就绪摘要-修改接口-前端待处理-管理后台.md';
const CURRENT_5216 = 'changelogs-v2/2026-07/24_5216_派车看板补充槽位接送路线与就绪摘要-修改接口-管理后台.md';
function document({ canonicalPath = '', status = 'claimed', owner = 'frontend-team', frontendRef = '' } = {}) {
const canonicalLine = canonicalPath ? `canonical_path: "${canonicalPath}"\n` : '';
return `---
schema: "hl-changelog/v2"
ticket: "5216"
title: "路径兼容测试"
consumer: "admin"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "${status}"
frontend_owner: "${owner}"
frontend_ref: "${frontendRef}"
target_release: ""
verified_at: ""
${canonicalLine}updated_at: "2026-07-26"
base: "dev-v3"
---
# 路径兼容测试
## 变更接口
- 无业务接口变化
## 验证证据
- 自动化测试
`;
}
function createFixture() {
const root = mkdtempSync(path.join(tmpdir(), 'hl-changelog-alias-'));
const manifest = {
schema: 'hl-changelog-path-aliases/v1',
aliases: [{
ticket: '5216',
alias: OLD_5216,
canonical: CURRENT_5216,
reason: '测试旧路径兼容',
}],
};
writeFileSync(path.join(root, 'changelog-path-aliases.json'), JSON.stringify(manifest, null, 2));
for (const repoPath of [OLD_5216, CURRENT_5216]) {
const absolute = path.join(root, ...repoPath.split('/'));
mkdirSync(path.dirname(absolute), { recursive: true });
writeFileSync(absolute, document({
canonicalPath: repoPath === OLD_5216 ? CURRENT_5216 : '',
}));
}
return root;
}
test('repository #5216 compatibility entry resolves to its canonical document', () => {
const manifest = loadPathAliasManifest();
assert.equal(resolveChangelogPath(OLD_5216, manifest), CURRENT_5216);
assert.equal(resolveChangelogPath(CURRENT_5216, manifest), CURRENT_5216);
assert.deepEqual(validatePathAliases().errors, []);
});
test('rejects unsafe paths, duplicate aliases and alias chains', () => {
assert.throws(() => parsePathAliasManifest(JSON.stringify({
schema: 'hl-changelog-path-aliases/v1',
aliases: [{
ticket: '5216',
alias: '../outside.md',
canonical: CURRENT_5216,
reason: 'unsafe',
}],
})), /POSIX|规范路径/);
assert.throws(() => parsePathAliasManifest(JSON.stringify({
schema: 'hl-changelog-path-aliases/v1',
aliases: [
{ ticket: '5216', alias: OLD_5216, canonical: CURRENT_5216, reason: 'one' },
{ ticket: '5216', alias: OLD_5216, canonical: CURRENT_5216, reason: 'two' },
],
})), /重复/);
});
test('frontend status transition updates alias group once and is idempotent', () => {
const root = createFixture();
try {
const first = transitionAliasedFrontendState(root, OLD_5216, {
status: 'implemented',
owner: 'frontend-team',
frontendRef: 'mmg/hl-ui@abc1234',
}, {
now: new Date('2026-07-26T04:00:00Z'),
write: true,
});
assert.deepEqual(new Set(first.changedPaths), new Set([OLD_5216, CURRENT_5216]));
assert.deepEqual(validatePathAliases(root).errors, []);
for (const repoPath of [OLD_5216, CURRENT_5216]) {
const text = readFileSync(path.join(root, ...repoPath.split('/')), 'utf8');
const { metadata } = parseFrontmatter(text);
assert.equal(metadata.frontend_status, 'implemented');
assert.equal(metadata.frontend_owner, 'frontend-team');
assert.equal(metadata.frontend_ref, 'mmg/hl-ui@abc1234');
}
const second = transitionAliasedFrontendState(root, OLD_5216, {
status: 'implemented',
owner: 'frontend-team',
frontendRef: 'mmg/hl-ui@abc1234',
}, {
now: new Date('2026-07-26T04:00:00Z'),
write: true,
});
assert.deepEqual(second.changedPaths, []);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

查看文件

@ -10,6 +10,7 @@ import {
collectNewTargetPaths,
getShanghaiDate,
parseNameStatusZ,
pathAliasMapFromManifest,
runValidation,
validateChangelogPath,
} from '../scripts/validate-changelog-filenames.mjs';
@ -114,13 +115,19 @@ test('parses NUL-delimited Chinese and space-containing paths', () => {
]);
});
test('exempts modified and deleted historical bad paths', () => {
const records = parseNameStatusZ(Buffer.from(
'M\0changelogs-v2/2026-07/63_历史坏文件.md\0D\0changelogs-v2/2026-07/99_无issue.md\0',
test('exempts a modified historical bad path but protects a deleted published path', () => {
const modified = parseNameStatusZ(Buffer.from(
'M\0changelogs-v2/2026-07/63_历史坏文件.md\0',
'utf8',
));
assert.deepEqual(collectNewTargetPaths(records), []);
assert.equal(runValidation(records, SHANGHAI_NOW).errors.length, 0);
assert.deepEqual(collectNewTargetPaths(modified), []);
assert.equal(runValidation(modified, SHANGHAI_NOW).errors.length, 0);
const deleted = parseNameStatusZ(Buffer.from(
'D\0changelogs-v2/2026-07/99_无issue.md\0',
'utf8',
));
assert.ok(runValidation(deleted, SHANGHAI_NOW).errors.some(({ code }) => code === 'E_PATH_STABILITY'));
});
test('validates only a copy target path', () => {
@ -128,9 +135,23 @@ test('validates only a copy target path', () => {
assert.deepEqual(collectNewTargetPaths(records), [validAdmin()]);
});
test('accepts a rename from an old bad name to a valid target', () => {
const records = parseNameStatusZ(Buffer.from(`R100\0changelogs-v2/2026-07/63_old.md\0${validAdmin()}\0`, 'utf8'));
assert.equal(runValidation(records, SHANGHAI_NOW).errors.length, 0);
test('rejects a rename without a machine-readable compatibility alias', () => {
const source = 'changelogs-v2/2026-07/63_old.md';
const records = parseNameStatusZ(Buffer.from(`R100\0${source}\0${validAdmin()}\0`, 'utf8'));
assert.ok(runValidation(records, SHANGHAI_NOW).errors.some(({ code }) => code === 'E_PATH_STABILITY'));
});
test('accepts a rename and a legacy alias path when both are registered', () => {
const source = 'changelogs-v2/2026-07/63_old.md';
const target = validAdmin();
const pathAliases = pathAliasMapFromManifest({
schema: 'hl-changelog-path-aliases/v1',
aliases: [{ alias: source, canonical: target }],
});
const rename = parseNameStatusZ(Buffer.from(`R100\0${source}\0${target}\0`, 'utf8'));
assert.equal(runValidation(rename, SHANGHAI_NOW, { pathAliases }).errors.length, 0);
const compatibilityAddition = parseNameStatusZ(Buffer.from(`A\0${source}\0`, 'utf8'));
assert.equal(runValidation(compatibilityAddition, SHANGHAI_NOW, { pathAliases }).errors.length, 0);
});
test('rejects a rename target with an old day', () => {