362 行
12 KiB
JavaScript
362 行
12 KiB
JavaScript
#!/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();
|
|
}
|