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
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env sh
HOOK_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(git -C "$HOOK_DIR" rev-parse --show-toplevel 2>/dev/null)
if [ -z "$REPO_ROOT" ]; then
echo "FAIL: 无法确定 Git 仓库根目录,中文提交门禁未执行。" >&2
exit 1
fi
if ! command -v node >/dev/null 2>&1; then
echo "FAIL: 未找到 Node.js,无法执行中文提交门禁。" >&2
exit 1
fi
exec node "$REPO_ROOT/scripts/check-commit-message.mjs" --file "$1"
+16
View File
@@ -108,6 +108,22 @@ WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running
## 持续开发规则 ## 持续开发规则
### 一次性启用 Git 中文提交门禁
每次新克隆仓库后必须执行一次安装脚本;Git hook 不会仅因克隆仓库自动启用。Windows 在仓库内执行:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\setup-git-hooks.ps1
```
WSL/Ubuntu 在仓库内执行:
```bash
sh scripts/setup-git-hooks.sh
```
脚本会把当前克隆的 `core.hooksPath` 配置为 `.githooks`。之后每次提交都会校验首行符合 `<type>(<scope>): 中文摘要`;可单独运行 `node scripts/tests/check-commit-message.test.mjs` 验证门禁。
- 唯一模块顺序:`M00 → M01 → M02 → … → M10`,模块内按 `A → B → C → …` - 唯一模块顺序:`M00 → M01 → M02 → … → M10`,模块内按 `A → B → C → …`
- `docs/module-status.md` 顶部的 `execution_cursor` 是唯一续接游标;Codex 不重新规划、不从 M00 重来。 - `docs/module-status.md` 顶部的 `execution_cursor` 是唯一续接游标;Codex 不重新规划、不从 M00 重来。
- 一个 commit 只完成一个子阶段;同一次 Codex 会话可连续完成多个相邻子阶段。 - 一个 commit 只完成一个子阶段;同一次 Codex 会话可连续完成多个相邻子阶段。
+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();
}
+2
View File
@@ -12,6 +12,8 @@ Assert-NativeSuccess "check-workspace"
Assert-NativeSuccess "check-reference" Assert-NativeSuccess "check-reference"
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-line-endings.ps1 & powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-line-endings.ps1
Assert-NativeSuccess "check-line-endings" Assert-NativeSuccess "check-line-endings"
node scripts/tests/check-commit-message.test.mjs
Assert-NativeSuccess "check-commit-message"
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-large-files.ps1 & powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-large-files.ps1
Assert-NativeSuccess "check-large-files" Assert-NativeSuccess "check-large-files"
& powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-repo-completeness.ps1 & powershell -ExecutionPolicy Bypass -File scripts/dev/windows/check-repo-completeness.ps1
+30
View File
@@ -0,0 +1,30 @@
$ErrorActionPreference = "Stop"
function ConvertFrom-Utf8Base64([string]$Value) {
return [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Value))
}
function Invoke-Git([string[]]$Arguments) {
$Output = & git @Arguments
if ($LASTEXITCODE -ne 0) {
$Prefix = ConvertFrom-Utf8Base64 "R2l0IOWRveS7pOaJp+ihjOWksei0pe+8mg=="
throw "$Prefix git $($Arguments -join ' ')"
}
return $Output
}
$RepoRoot = (Invoke-Git @("rev-parse", "--show-toplevel") | Select-Object -First 1).Trim()
$HookFile = Join-Path $RepoRoot ".githooks\commit-msg"
if (-not (Test-Path -LiteralPath $HookFile -PathType Leaf)) {
$Prefix = ConvertFrom-Utf8Base64 "5pyq5om+5Yiw5o+Q5Lqk6Zeo56aB77ya"
throw "$Prefix$HookFile"
}
Invoke-Git @("-C", $RepoRoot, "config", "--local", "core.hooksPath", ".githooks") | Out-Null
$ConfiguredPath = (Invoke-Git @("-C", $RepoRoot, "config", "--local", "--get", "core.hooksPath") | Select-Object -First 1).Trim()
if ($ConfiguredPath -ne ".githooks") {
$Prefix = ConvertFrom-Utf8Base64 "R2l0IGhvb2tzUGF0aCDphY3nva7moKHpqozlpLHotKXvvIzlvZPliY3lgLzvvJo="
throw "$Prefix$ConfiguredPath"
}
Write-Host (ConvertFrom-Utf8Base64 "UEFTUzog5bey5Li65b2T5YmN5YWL6ZqG5ZCv55SoIEdpdCDkuK3mlofmj5DkuqTpl6jnpoHvvIhjb3JlLmhvb2tzUGF0aD0uZ2l0aG9va3PvvInjgII=")
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env sh
set -eu
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
echo "FAIL: 当前目录不在 Git 仓库中。" >&2
exit 1
}
hook_file="$repo_root/.githooks/commit-msg"
if [ ! -f "$hook_file" ]; then
echo "FAIL: 未找到提交门禁:$hook_file" >&2
exit 1
fi
chmod +x "$hook_file"
git -C "$repo_root" config --local core.hooksPath .githooks
configured_path=$(git -C "$repo_root" config --local --get core.hooksPath)
if [ "$configured_path" != ".githooks" ]; then
echo "FAIL: Git hooksPath 配置校验失败,当前值:$configured_path" >&2
exit 1
fi
echo "PASS: 已为当前克隆启用 Git 中文提交门禁(core.hooksPath=.githooks)。"
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
import {
CommitMessageError,
checkCommitMessage,
parseAllowedTypes,
} from '../check-commit-message.mjs';
const checkerPath = resolve('scripts/check-commit-message.mjs');
test('接受符合格式的中文摘要', () => {
const result = checkCommitMessage('feat(M09-D1): 建立商品与库存流水底座');
assert.equal(result.type, 'feat');
assert.equal(result.scope, 'M09-D1');
});
test('接受技术英文与中文混合摘要', () => {
checkCommitMessage('fix(M09-D2): 修复 MySQL CAS 并发扣减竞态');
});
test('只校验首行并允许多行正文', () => {
checkCommitMessage('docs(spec): 固化 V5.7 中文提交规则\n\n补充风险、验证结果与回滚说明。');
});
test('拒绝全英文摘要', () => {
assert.throws(
() => checkCommitMessage('feat(M09-D1): add product inventory foundation'),
(error) => error instanceof CommitMessageError && /CJK/u.test(error.message),
);
});
test('拒绝空摘要', () => {
assert.throws(() => checkCommitMessage('feat(M09-D1): '), /摘要不能为空/u);
});
test('拒绝 Git 默认 Merge 与 Revert 标题', () => {
assert.throws(() => checkCommitMessage("Merge branch 'main'"), /Merge\/Revert/u);
assert.throws(() => checkCommitMessage('Revert "feat: add inventory"'), /Merge\/Revert/u);
});
test('接受改写为规范格式的中文回退标题', () => {
checkCommitMessage('revert(M09-D1): 回退库存流水迁移');
});
test('拒绝缺少 scope 或不允许的类型', () => {
assert.throws(() => checkCommitMessage('chore: 更新文档'), /格式错误/u);
assert.throws(() => checkCommitMessage('release(M09-D1): 发布库存底座'), /不允许的提交类型/u);
});
test('可以覆盖允许的 Conventional Commits 类型', () => {
const allowedTypes = parseAllowedTypes('feat,release');
checkCommitMessage('release(M09-D1): 发布库存底座', { allowedTypes });
assert.throws(() => checkCommitMessage('fix(M09-D1): 修复库存', { allowedTypes }), /不允许的提交类型/u);
});
test('命令行支持提交消息文件与传入字符串', async () => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'qipai-commit-message-'));
const messageFile = join(temporaryDirectory, 'COMMIT_EDITMSG');
try {
await writeFile(messageFile, 'test(M09-D1): 补充库存并发测试\n\n验证锁定与释放。\n', 'utf8');
const fileResult = spawnSync(process.execPath, [checkerPath, '--file', messageFile], { encoding: 'utf8' });
assert.equal(fileResult.status, 0, fileResult.stderr);
assert.match(fileResult.stdout, /PASS/u);
const stringResult = spawnSync(
process.execPath,
[checkerPath, '--message', 'build(V5.7): 接入 Git 中文提交门禁'],
{ encoding: 'utf8' },
);
assert.equal(stringResult.status, 0, stringResult.stderr);
const rejectedResult = spawnSync(
process.execPath,
[checkerPath, '--message', 'build(V5.7): wire commit gate'],
{ encoding: 'utf8' },
);
assert.equal(rejectedResult.status, 1);
assert.match(rejectedResult.stderr, /FAIL/u);
const missingFileResult = spawnSync(
process.execPath,
[checkerPath, '--file', join(temporaryDirectory, 'missing-message')],
{ encoding: 'utf8' },
);
assert.equal(missingFileResult.status, 1);
assert.match(missingFileResult.stderr, /无法读取提交消息文件/u);
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
});