#!/usr/bin/env node import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; const CONTROLLED_ROOTS = new Map([ ['changelogs-v2', '管理后台'], ['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 }; } export function getShanghaiDate(now = new Date()) { if (!(now instanceof Date) || Number.isNaN(now.getTime())) { throw new TypeError('The validation clock must be a valid 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])); if (!/^\d{4}$/.test(values.year) || !/^\d{2}$/.test(values.month) || !/^\d{2}$/.test(values.day)) { throw new Error('Unable to derive the current Asia/Shanghai calendar date.'); } return { yearMonth: `${values.year}-${values.month}`, day: values.day, isoDate: `${values.year}-${values.month}-${values.day}`, }; } export function parseNameStatusZ(raw) { const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw ?? ''); const tokens = text.split('\0'); if (tokens.at(-1) === '') { tokens.pop(); } const records = []; for (let index = 0; index < tokens.length;) { const status = tokens[index++]; if (!status) { throw new Error('Malformed git diff: an empty status token was found.'); } if (/^[RC]\d{1,3}$/.test(status)) { const sourcePath = tokens[index++]; const targetPath = tokens[index++]; if (sourcePath === undefined || targetPath === undefined) { throw new Error(`Malformed git diff: ${status} must contain source and target paths.`); } records.push({ status, sourcePath, targetPath }); continue; } const targetPath = tokens[index++]; if (targetPath === undefined) { throw new Error(`Malformed git diff: ${status} is missing its path.`); } records.push({ status, targetPath }); } return records; } export function collectNewTargetPaths(records) { return records .filter(({ status }) => status === 'A' || /^C\d{1,3}$/.test(status) || /^R\d{1,3}$/.test(status)) .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()) { if (candidate === root || candidate.startsWith(`${root}/`) || candidate.startsWith(`${root}\\`)) { return root; } } return undefined; } export function validateChangelogPath(inputPath, now = new Date()) { const changelogPath = String(inputPath); const root = controlledRootForPath(changelogPath); if (!root) { return []; } const expectedClient = CONTROLLED_ROOTS.get(root); if (changelogPath.includes('\\')) { return [ruleError( 'E_STRUCTURE', changelogPath, '受控 Git 路径包含反斜杠;反斜杠是文件名字节,不是路径分隔符', `${root}/YYYY-MM/DD_issue_标题-{新增接口|修改接口|删除接口}-${expectedClient}.md`, )]; } const expectedDate = getShanghaiDate(now); const errors = []; const segments = changelogPath.split('/'); if (segments.length !== 3 || !segments[2].endsWith('.md')) { return [ruleError( 'E_STRUCTURE', changelogPath, '路径层级或扩展名不符合规则', `${root}/YYYY-MM/DD_issue_标题-{新增接口|修改接口|删除接口}-${expectedClient}.md`, )]; } const [, yearMonth, filename] = segments; if (yearMonth !== expectedDate.yearMonth) { errors.push(ruleError( 'E_MONTH', changelogPath, `月目录为 ${yearMonth}`, `Asia/Shanghai 当天的 ${expectedDate.yearMonth}`, )); } const stem = filename.slice(0, -3); const clientSeparator = stem.lastIndexOf('-'); const actualClient = clientSeparator >= 0 ? stem.slice(clientSeparator + 1) : ''; const beforeClient = clientSeparator >= 0 ? stem.slice(0, clientSeparator) : stem; if (actualClient !== expectedClient) { errors.push(ruleError( 'E_CLIENT', changelogPath, `端类型为 ${actualClient || '(缺失)'}`, expectedClient, )); } const typeSeparator = beforeClient.lastIndexOf('-'); const actualType = typeSeparator >= 0 ? beforeClient.slice(typeSeparator + 1) : ''; const prefix = typeSeparator >= 0 ? beforeClient.slice(0, typeSeparator) : beforeClient; if (!CHANGE_TYPES.has(actualType)) { errors.push(ruleError( 'E_CHANGE_TYPE', changelogPath, `变更类型为 ${actualType || '(缺失)'}`, '新增接口 / 修改接口 / 删除接口', )); } const prefixMatch = /^(\d{2})_([^_]*)_(.*)$/.exec(prefix); if (!prefixMatch) { const dayMatch = /^(\d{2})_/.exec(prefix); if (dayMatch && dayMatch[1] !== expectedDate.day) { errors.push(ruleError('E_DAY', changelogPath, `日前缀为 ${dayMatch[1]}`, expectedDate.day)); } errors.push(ruleError( 'E_ISSUE', changelogPath, '文件名缺少可识别的正整数 Issue 号', 'DD_[1-9][0-9]*_标题', )); return errors; } const [, day, issue, title] = prefixMatch; if (day !== expectedDate.day) { errors.push(ruleError('E_DAY', changelogPath, `日前缀为 ${day}`, expectedDate.day)); } if (!/^[1-9]\d*$/.test(issue)) { errors.push(ruleError('E_ISSUE', changelogPath, `Issue 号为 ${issue || '(缺失)'}`, '不带 # 的十进制正整数')); } if (title.trim() === '') { errors.push(ruleError('E_TITLE', changelogPath, '业务标题为空', '非空业务标题')); } return errors; } 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) => ( 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, errors, expectedDate: getShanghaiDate(now), }; } export function parseArguments(argv) { const options = {}; for (let index = 0; index < argv.length; index += 2) { const flag = argv[index]; const value = argv[index + 1]; if (!['--event', '--base', '--head'].includes(flag) || value === undefined) { throw new Error(`Unsupported or incomplete argument: ${flag ?? '(missing)'}`); } options[flag.slice(2)] = value; } if (options.event && (options.base || options.head)) { throw new Error('--event cannot be combined with --base/--head.'); } if (!options.event && (!options.base || !options.head)) { throw new Error('Use --event or both --base --head .'); } return options; } function runGit(args, options = {}) { return execFileSync('git', args, { ...options, stdio: ['pipe', 'pipe', 'pipe'], }); } function resolveCommitRef(ref, label) { if (typeof ref !== 'string' || ref.length === 0 || /[\0\r\n]/.test(ref)) { throw new Error(`${label} must be a non-empty Git ref without control characters.`); } let resolved; try { resolved = runGit(['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], { encoding: 'utf8' }).trim(); } catch { throw new Error(`${label} does not resolve to an existing commit.`); } if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(resolved)) { throw new Error(`${label} resolved to an unexpected object ID.`); } return resolved; } function resolveEventCommitSha(sha, label) { if (typeof sha !== 'string' || !/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/.test(sha)) { throw new Error(`${label} must be a full 40- or 64-character hexadecimal commit SHA.`); } return resolveCommitRef(sha.toLowerCase(), label); } function emptyTreeObjectId() { const objectId = runGit(['hash-object', '-t', 'tree', '--stdin'], { input: Buffer.alloc(0), encoding: 'utf8', }).trim(); if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(objectId)) { throw new Error('Git returned an unexpected empty-tree object ID.'); } return objectId; } function gitDiff(revisions) { return runGit(['diff', '--name-status', '-z', '--find-renames', ...revisions, '--'], { encoding: 'buffer', maxBuffer: 64 * 1024 * 1024, }); } export function diffFromOptions(options) { if (!options.event) { const base = resolveCommitRef(options.base, 'base ref'); const head = resolveCommitRef(options.head, 'head ref'); return gitDiff([`${base}...${head}`]); } const event = JSON.parse(readFileSync(options.event, 'utf8')); if (event.pull_request) { const base = event.pull_request.base?.sha; const head = event.pull_request.head?.sha; if (!base || !head) { throw new Error('The pull_request event is missing base/head SHA values.'); } const resolvedBase = resolveEventCommitSha(base, 'pull_request base SHA'); const resolvedHead = resolveEventCommitSha(head, 'pull_request head SHA'); return gitDiff([`${resolvedBase}...${resolvedHead}`]); } if (event.before && event.after) { if (typeof event.before !== 'string' || !/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/.test(event.before)) { throw new Error('push before SHA must be a full 40- or 64-character hexadecimal value.'); } const before = /^0+$/.test(event.before) ? emptyTreeObjectId() : resolveEventCommitSha(event.before, 'push before SHA'); const after = resolveEventCommitSha(event.after, 'push after SHA'); return gitDiff([before, after]); } throw new Error('Unsupported event payload: expected pull_request or push before/after SHA values.'); } export function main(argv = process.argv.slice(2)) { try { const options = parseArguments(argv); const records = parseNameStatusZ(diffFromOptions(options)); 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}`); } console.error(`FAIL: found ${new Set(result.errors.map(({ path }) => path)).size} invalid changelog path(s) among ${result.checkedCount} controlled changelog path(s).`); return 1; } console.log(`PASS: validated ${result.checkedCount} controlled changelog path(s) from ${result.targetCount} new target path(s); expected Shanghai date ${result.expectedDate.isoDate}.`); 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(); }