diff --git a/.gitea/workflows/validate-changelog-filenames.yml b/.gitea/workflows/validate-changelog-filenames.yml index 9965474..082b927 100644 --- a/.gitea/workflows/validate-changelog-filenames.yml +++ b/.gitea/workflows/validate-changelog-filenames.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f32a8a8..0d7066f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 -- ... --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 与服务端阻断边界 diff --git a/FRONTEND_CONSUMPTION_STATUS_GUIDE.md b/FRONTEND_CONSUMPTION_STATUS_GUIDE.md index 19ef852..5793d95 100644 --- a/FRONTEND_CONSUMPTION_STATUS_GUIDE.md +++ b/FRONTEND_CONSUMPTION_STATUS_GUIDE.md @@ -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 命令同步状态。 diff --git a/changelog-path-aliases.json b/changelog-path-aliases.json new file mode 100644 index 0000000..75ea363 --- /dev/null +++ b/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": "兼容前端消费线程在文件重命名前持久化的稳定路径" + } + ] +} diff --git a/changelogs-v2/2026-07/24_5216_派车看板补充槽位接送路线与就绪摘要-修改接口-前端待处理-管理后台.md b/changelogs-v2/2026-07/24_5216_派车看板补充槽位接送路线与就绪摘要-修改接口-前端待处理-管理后台.md new file mode 100644 index 0000000..40df049 --- /dev/null +++ b/changelogs-v2/2026-07/24_5216_派车看板补充槽位接送路线与就绪摘要-修改接口-前端待处理-管理后台.md @@ -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 校验与状态同步测试覆盖读取、同步写入和重复写入。 diff --git a/changelogs-v2/2026-07/24_5216_派车看板补充槽位接送路线与就绪摘要-修改接口-管理后台.md b/changelogs-v2/2026-07/24_5216_派车看板补充槽位接送路线与就绪摘要-修改接口-管理后台.md index a99c938..ec789bf 100644 --- a/changelogs-v2/2026-07/24_5216_派车看板补充槽位接送路线与就绪摘要-修改接口-管理后台.md +++ b/changelogs-v2/2026-07/24_5216_派车看板补充槽位接送路线与就绪摘要-修改接口-管理后台.md @@ -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" --- diff --git a/package.json b/package.json index c198b01..f1609bd 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/changelog-path-aliases.mjs b/scripts/changelog-path-aliases.mjs new file mode 100644 index 0000000..42e06f8 --- /dev/null +++ b/scripts/changelog-path-aliases.mjs @@ -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 | --transition [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(); +} diff --git a/scripts/validate-changelog-filenames.mjs b/scripts/validate-changelog-filenames.mjs index 068632c..3f70f7d 100644 --- a/scripts/validate-changelog-filenames.mjs +++ b/scripts/validate-changelog-filenames.mjs @@ -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}`); diff --git a/tests/changelog-path-aliases.test.mjs b/tests/changelog-path-aliases.test.mjs new file mode 100644 index 0000000..601371e --- /dev/null +++ b/tests/changelog-path-aliases.test.mjs @@ -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 }); + } +}); diff --git a/tests/validate-changelog-filenames.test.mjs b/tests/validate-changelog-filenames.test.mjs index b47a226..280246f 100644 --- a/tests/validate-changelog-filenames.test.mjs +++ b/tests/validate-changelog-filenames.test.mjs @@ -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', () => {