170 lines
5.6 KiB
JavaScript
170 lines
5.6 KiB
JavaScript
#!/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();
|
|
}
|