#!/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(); }