#!/usr/bin/env node import { readFile } from 'node:fs/promises'; import { statSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; export const DEFAULT_ALLOWED_TYPES = Object.freeze([ 'feat', 'fix', 'refactor', 'test', 'build', 'chore', 'docs', 'perf', 'ci', 'style', 'revert', ]); const SUBJECT_PATTERN = /^([a-z][a-z0-9-]*)\(([A-Za-z0-9][A-Za-z0-9._/-]{0,31})\): (.*)$/u; const CJK_UNIFIED_IDEOGRAPH_PATTERN = /\p{Unified_Ideograph}/u; const GENERATED_SUBJECT_PATTERN = /^(?:Merge(?:\s|$)|Revert(?:\s|$))/u; export class CommitMessageError extends Error { constructor(message) { super(message); this.name = 'CommitMessageError'; } } export function parseAllowedTypes(value) { const types = String(value ?? '') .split(',') .map((type) => type.trim()) .filter(Boolean); if (types.length === 0 || types.some((type) => !/^[a-z][a-z0-9-]*$/u.test(type))) { throw new CommitMessageError('允许的提交类型必须是逗号分隔的小写 Conventional Commits 类型。'); } return [...new Set(types)]; } export function checkCommitMessage(message, options = {}) { if (typeof message !== 'string') { throw new CommitMessageError('提交消息必须是字符串。'); } const subject = (message.split(/\r?\n/u, 1)[0] ?? '').replace(/^\uFEFF/u, ''); if (!subject.trim()) { throw new CommitMessageError('提交标题不能为空。'); } if (GENERATED_SUBJECT_PATTERN.test(subject)) { throw new CommitMessageError('Git 自动生成的 Merge/Revert 英文标题不可提交,请改写为“(): 中文摘要”。'); } const match = SUBJECT_PATTERN.exec(subject); if (!match) { throw new CommitMessageError('提交标题格式错误,应为“(): 中文摘要”,且 scope 不得为空或包含空格。'); } const [, type, scope, summary] = match; const allowedTypes = options.allowedTypes ?? DEFAULT_ALLOWED_TYPES; if (!allowedTypes.includes(type)) { throw new CommitMessageError(`不允许的提交类型“${type}”;允许类型:${allowedTypes.join(', ')}。`); } if (!summary.trim()) { throw new CommitMessageError('冒号后的中文摘要不能为空。'); } if (!CJK_UNIFIED_IDEOGRAPH_PATTERN.test(summary)) { throw new CommitMessageError('冒号后的摘要必须至少包含一个 CJK 统一表意文字,禁止全英文摘要。'); } return { subject, type, scope, summary }; } function usage() { return [ '用法:', ' node scripts/check-commit-message.mjs <提交消息文件>', ' node scripts/check-commit-message.mjs --file <提交消息文件>', ' node scripts/check-commit-message.mjs --message "<提交消息>"', '可选:--types feat,fix,docs(覆盖默认允许类型)', ].join('\n'); } async function readCliInput(argv) { let filePath; let literalMessage; let allowedTypes = DEFAULT_ALLOWED_TYPES; const positional = []; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === '--help' || argument === '-h') { return { help: true }; } if (argument === '--file' || argument === '-f') { filePath = argv[++index]; if (!filePath) throw new CommitMessageError('--file 后必须提供提交消息文件路径。'); continue; } if (argument === '--message' || argument === '-m') { literalMessage = argv[++index]; if (literalMessage === undefined) throw new CommitMessageError('--message 后必须提供提交消息字符串。'); continue; } if (argument === '--types') { const value = argv[++index]; if (value === undefined) throw new CommitMessageError('--types 后必须提供允许类型。'); allowedTypes = parseAllowedTypes(value); continue; } positional.push(argument); } if (filePath !== undefined && literalMessage !== undefined) { throw new CommitMessageError('--file 与 --message 不能同时使用。'); } if (positional.length > 1 || ((filePath !== undefined || literalMessage !== undefined) && positional.length > 0)) { throw new CommitMessageError(`参数不合法。\n${usage()}`); } if (filePath === undefined && literalMessage === undefined && positional.length === 1) { const candidate = positional[0]; try { if (statSync(candidate).isFile()) filePath = candidate; else literalMessage = candidate; } catch { literalMessage = candidate; } } if (filePath === undefined && literalMessage === undefined) { throw new CommitMessageError(`缺少提交消息文件或字符串。\n${usage()}`); } let message = literalMessage; if (filePath !== undefined) { try { message = await readFile(resolve(filePath), 'utf8'); } catch { throw new CommitMessageError(`无法读取提交消息文件:${filePath}`); } } return { message, allowedTypes, help: false }; } async function main() { try { const input = await readCliInput(process.argv.slice(2)); if (input.help) { process.stdout.write(`${usage()}\n`); return; } const result = checkCommitMessage(input.message, { allowedTypes: input.allowedTypes }); process.stdout.write(`PASS: 提交标题符合中文门禁:${result.subject}\n`); } catch (error) { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`FAIL: ${message}\n`); process.exitCode = 1; } } const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ''; if (invokedPath && invokedPath === fileURLToPath(import.meta.url)) { await main(); }