This commit is contained in:
2026-10-21 14:42:59 +08:00
commit 954236d3f9
399 changed files with 85885 additions and 0 deletions

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env node
// scripts/commit-msg-preprocess.cjs
//
// 把 commit message 转成 commitlint 能验证的 conventional 格式,绝不卡用户:
//
// [audit] phase 2 — ...
// → audit: phase 2 — ... (项目主流 [prefix] 风格;prefix 强制小写)
//
// feat add new button
// → feat: add new button (bare 首词命中 type-enum → 用作 type)
//
// feat:word (常见 typo —— 冒号后忘空格)
// → feat: word (顺带补空格)
//
// update xxx
// → chore: update xxx (bare 首词不在 enum → 默认 chore)
//
// update (单字 bare)
// → chore: update (默认 chore)
//
// feat: existing conventional
// → feat: existing conventional (已 conventional 不动)
//
// 只动第一行(header),body/footer 不动。type-enum 从 commitlint.config.cjs
// 同步读取 —— audit round 8 (2026-09-14) commitlint.config 现在顶层导出
// .types 数组作为单一真相,这里优先读 config.types,失败再退到
// config.rules['type-enum'][2](老格式),再不行才用本文件末尾 FALLBACK_TYPES。
//
// 调用方式(在 .husky/commit-msg 里):node scripts/commit-msg-preprocess.cjs "$1"
const fs = require('node:fs');
const path = require('node:path');
// FALLBACK_TYPES 现在是真正的兜底(双 require 失败 / config 损坏),
// 不再与 commitlint.config 双源同步。commitlint.config 是唯一真相,
// 这里 hardcode 是为了让 loadTypeEnum 在异常路径上仍能给一个非空集合。
const FALLBACK_TYPES = new Set([
'workbench', 'cleanup', 'ui', 'docs', 'spec', 'plan', 'test', 'audit',
'feat', 'fix', 'refactor', 'perf', 'style', 'build', 'ci', 'chore', 'revert',
]);
function loadTypeEnum() {
try {
const configPath = path.resolve(__dirname, '..', 'commitlint.config.cjs');
delete require.cache[require.resolve(configPath)];
const config = require(configPath);
// audit round 8 (2026-09-14):优先读 config.types —— 单一真相。
// 旧实现走 config.rules['type-enum'][2] 拿数组,需要先校验结构。
// 现在 commitlint.config.cjs 顶层直接导出 types 数组,这里 require
// 拿到即可,避免双重结构解码。失败时仍可走 type-enum 数组兜底
// (兼容老 commitlint.config 没升级 .types 字段的过渡期),再不行
// 才退回 FALLBACK_TYPES。
if (Array.isArray(config && config.types)) {
return new Set(config.types);
}
const enumRule = config && config.rules && config.rules['type-enum'];
if (Array.isArray(enumRule) && Array.isArray(enumRule[2])) {
return new Set(enumRule[2]);
}
} catch (_) {
/* fall through to fallback */
}
return FALLBACK_TYPES;
}
const CONVENTIONAL_HEADER = /^\w+(?:\([^)]+\))?!?:\s.+$/;
const BRACKET_HEADER = /^\[(\w+)\]\s+(.+)$/;
function preprocess(message, typeSet) {
const set = typeSet || loadTypeEnum();
const newline = message.includes('\r\n') ? '\r\n' : '\n';
const lines = message.split(/\r?\n/);
const header = lines[0] || '';
// 空 header —— 让 commitlint 自然报错,不要硬塞一个虚假 chore:
if (!header.trim()) {
return message;
}
// [prefix] subject → prefix: subject (prefix 强制小写,匹配 type-case 规则;
// 未注册的 prefix 直接落到 bare fallback,避免 "[WIP] stuff" 这种被静默转成
// "wip: stuff" 然后 commitlint 用 "type not in enum" 二次卡住用户)
const bracketMatch = header.match(BRACKET_HEADER);
if (bracketMatch) {
const [, type, subject] = bracketMatch;
const lower = type.toLowerCase();
if (set.has(lower)) {
lines[0] = `${lower}: ${subject}`;
return lines.join(newline);
}
}
// 已 conventional → 原样不动(commitlint 自家规则会校验)
if (CONVENTIONAL_HEADER.test(header)) {
return message;
}
// bare:首词是已知 type(忽略大小写)且后面有内容 → "<type>: <rest>"
// 同时吞掉紧跟首词的 ":"(常见 typo "feat:word"),保证输出干净
const firstWord = header.match(/^(\w+)\b/);
if (firstWord) {
const typeCandidate = firstWord[1].toLowerCase();
if (set.has(typeCandidate)) {
const rest = header
.slice(firstWord[0].length)
.replace(/^:\s*/, '')
.trimStart();
if (rest.length > 0) {
lines[0] = `${typeCandidate}: ${rest}`;
return lines.join(newline);
}
}
}
// bare fallback → "chore: <header>" —— 兜底,确保 commit 永远成功
lines[0] = `chore: ${header}`;
return lines.join(newline);
}
if (require.main === module) {
const file = process.argv[2];
if (!file) {
console.error(
'[commit-msg-preprocess] usage: commit-msg-preprocess.cjs <commit-msg-file>',
);
process.exit(2);
}
const original = fs.readFileSync(file, 'utf8');
const rewritten = preprocess(original);
if (rewritten !== original) {
// audit round 8 (2026-09-14):写 tmp + rename 替直接 writeFileSync ——
// 旧实现写中断(磁盘满 / OS kill / power loss)会让 commit-msg 文件
// 半空,git 直接 reject "no commit message",用户措手不及。改成
// tmp 写 + renameSync:POSIX rename 原子,Windows 上 NTFS MoveFileEx
// 同 target volume 时也是原子的;失败时 tmp 文件残留但原文件完好。
// tmp 用 file + '.tmp' 与 git 临时文件命名约定一致,git 重启可识别。
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, rewritten, 'utf8');
try {
fs.renameSync(tmp, file);
} catch (e) {
try {
fs.unlinkSync(tmp);
} catch (_) {
/* tmp 清理失败不阻塞 — 用户重试一次 commit 就会覆盖 */
}
console.error(`[commit-msg-preprocess] rename tmp -> file failed: ${e.message}`);
process.exit(1);
}
// 静默不输出 —— git commit 行为不要污染 stdout,提交消息自身就是反馈
}
process.exit(0);
}
module.exports = {
preprocess,
loadTypeEnum,
CONVENTIONAL_HEADER,
BRACKET_HEADER,
FALLBACK_TYPES,
};