chore(V5.7): 增加中文提交信息校验门禁

This commit is contained in:
Codex
2026-08-11 03:07:45 +08:00
parent fd6be4f9b9
commit 2de796143f
7 changed files with 353 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
#!/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 英文标题不可提交,请改写为“<type>(<scope>): 中文摘要”。');
}
const match = SUBJECT_PATTERN.exec(subject);
if (!match) {
throw new CommitMessageError('提交标题格式错误,应为“<type>(<scope>): 中文摘要”,且 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();
}