chore(V5.7): 增加中文提交信息校验门禁
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user