Commit f2dfb16a by pangchong

chore(tooling): 添加提交前自动规则校验脚本

- 新增 scripts/verify-rules.js,实现对暂存区代码的多项规范检查
- 配置 package.json 新增 verify-rules 脚本用于执行规则校验
- husky 钩子 pre-commit 调用 verify-rules,在格式化前阻断不合规提交
- 规则涵盖 Vue 文件结构、禁用 class 和 function 声明模式、禁止手动导入 Vue/Pinia API
- 强制统一异步处理为 async/await,禁止使用 .then/.catch 链式调用
- 限制灰度色硬编码,禁止原生某些组件,强制规定 UI 交互模式
- 违规时输出详细违规文件、行号和原因,阻止不规范代码提交
parent fb0f4d9b
#!/usr/bin/env sh #!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh" . "$(dirname -- "$0")/_/husky.sh"
# 1. 静态校验暂存区代码是否符合 .gemini/.rules.md 规范(不符合立即阻断提交)
npm run verify-rules
npm run format npm run format
echo '***************************************************' echo '***************************************************'
echo '********************注意提交格式*******************' echo '********************注意提交格式*******************'
......
...@@ -14,6 +14,7 @@ ...@@ -14,6 +14,7 @@
"preview": "vite preview", "preview": "vite preview",
"prepare": "husky install", "prepare": "husky install",
"commitlint": "commitlint --config commitlint.config.cjs -e -V", "commitlint": "commitlint --config commitlint.config.cjs -e -V",
"verify-rules": "node scripts/verify-rules.js",
"format": "prettier --write \"./**/*.{html,vue,ts,js,json,md}\"" "format": "prettier --write \"./**/*.{html,vue,ts,js,json,md}\""
}, },
"dependencies": { "dependencies": {
......
#!/usr/bin/env node
// @ts-nocheck
/**
* .gemini/.rules.md 自动化提交前规则校验脚本
*
* 作用:在 git commit 时自动对暂存区(Staged)的代码文件进行规则扫描。
* 若发现违反 .gemini/.rules.md 的规范,终端输出具体违规项并以状态码 1 退出,阻止 git commit 提交。
*/
import { execSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
// ANSI 控制台颜色
const RED = '\x1b[31m'
const GREEN = '\x1b[32m'
const YELLOW = '\x1b[33m'
const CYAN = '\x1b[36m'
const BOLD = '\x1b[1m'
const RESET = '\x1b[0m'
// 1. 获取需要扫描的目标文件(优先支持命令行传入具体路径,无参数时默认扫描 Git 暂存区 Staged 文件)
const args = process.argv.slice(2).filter((arg) => !arg.startsWith('--'))
let stagedFiles = []
if (args.length > 0) {
stagedFiles = args.filter((f) => /\.(vue|ts|js|tsx|jsx)$/.test(f))
} else {
try {
const stdout = execSync('git diff --cached --name-only --diff-filter=ACMR', { encoding: 'utf-8' })
stagedFiles = stdout
.split('\n')
.map((f) => f.trim())
.filter((f) => f && /\.(vue|ts|js|tsx|jsx)$/.test(f))
} catch (e) {
process.exit(0)
}
}
// 排除自身校验脚本以及 scripts 目录下的辅助文件
stagedFiles = stagedFiles.filter((f) => {
const normalized = f.replace(/\\/g, '/')
return !normalized.startsWith('scripts/') && !normalized.includes('verify-rules')
})
if (stagedFiles.length === 0) {
process.exit(0)
}
// 违规项记录列表
const violations = []
// 特殊物理渲染节点白名单(允许具体的物理样式定制)
const DOC_RENDER_WHITELIST = ['DocNodeRenderer', 'pdf', 'PDF', 'Signoff', 'DocViewer']
for (const relPath of stagedFiles) {
const fullPath = path.resolve(process.cwd(), relPath)
if (!fs.existsSync(fullPath)) continue
const content = fs.readFileSync(fullPath, 'utf-8')
const lines = content.split('\n')
const isVue = relPath.endsWith('.vue')
const isFunctionals = relPath.replace(/\\/g, '/').includes('/functionals/')
const isConstants = relPath.replace(/\\/g, '/').includes('/constants/')
const isTypes = relPath.endsWith('types.ts') || relPath.endsWith('types.d.ts')
const isDocNodeRenderer = DOC_RENDER_WHITELIST.some((w) => relPath.includes(w))
// ──────────────────────────────────────────────
// 规则 3: Vue 单文件结构必须是 <template> -> <script> -> <style>
// ──────────────────────────────────────────────
if (isVue) {
const templateIdx = content.indexOf('<template')
const scriptIdx = content.indexOf('<script')
const styleIdx = content.indexOf('<style')
if (templateIdx !== -1 && scriptIdx !== -1 && templateIdx > scriptIdx) {
violations.push({
file: relPath,
line: 1,
ruleName: '规则 3 (Vue 单文件结构规范)',
message: '<template> 必须位于 <script> 之前,请调整标签顺序'
})
}
if (scriptIdx !== -1 && styleIdx !== -1 && scriptIdx > styleIdx) {
violations.push({
file: relPath,
line: 1,
ruleName: '规则 3 (Vue 单文件结构规范)',
message: '<style> 必须位于 <script> 之后,请调整标签顺序'
})
}
}
// ──────────────────────────────────────────────
// 规则 2: Hook 内部函数声明规范 (functionals/index.ts)
// ──────────────────────────────────────────────
if (isFunctionals) {
// functionals 中不能书写 class
const classMatch = content.match(/\bclass\s+([A-Z][a-zA-Z0-9_]*)/)
if (classMatch) {
violations.push({
file: relPath,
line: 1,
ruleName: '规则 2 (避免硬编码与混杂 Class)',
message: `组件与模块级逻辑禁止使用 Class 进行封装 (${classMatch[0]}),应改用以 use 开头的逻辑 Hook 方法`
})
}
// Hook 内部禁止使用 function 关键字声明业务函数(必须使用 const 箭头函数)
lines.forEach((line, idx) => {
const trimmed = line.trim()
if (trimmed.startsWith('//') || trimmed.startsWith('*')) return
// 排除外层 export function useXxx 声明
if (/^\s*function\s+[a-zA-Z0-9_]+\s*\(/.test(line) && !/^\s*export\s+function\s+use[A-Z]/.test(line)) {
violations.push({
file: relPath,
line: idx + 1,
ruleName: '规则 2 (Hook 内部函数声明规范)',
message: 'Hook 内部业务函数禁止使用 function 关键字声明,必须使用 const 箭头函数',
codeSnippet: trimmed
})
}
})
}
// ──────────────────────────────────────────────
// 规则 2: 类型存放约束 (禁止在 .vue 或 functionals 中 export interface/type)
// ──────────────────────────────────────────────
if (isVue || (isFunctionals && !isTypes && !isConstants)) {
lines.forEach((line, idx) => {
const trimmed = line.trim()
if (trimmed.startsWith('//') || trimmed.startsWith('*')) return
if (/^\s*export\s+(interface|type)\s+[A-Za-z0-9_]+/.test(line)) {
violations.push({
file: relPath,
line: idx + 1,
ruleName: '规则 2 (TypeScript 类型存放约束)',
message: '模块或组件相关的 interface/type 必须统一定义在同级 constants/index.ts 中,严禁在此导出',
codeSnippet: trimmed
})
}
})
}
// ──────────────────────────────────────────────
// 行级逐行模式校验
// ──────────────────────────────────────────────
lines.forEach((line, idx) => {
const lineNum = idx + 1
const trimmed = line.trim()
// 忽略纯注释行
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*') || trimmed.startsWith('<!--')) {
return
}
// 规则 7: 严禁使用 useMessage()
if (/\buseMessage\s*\(/.test(line)) {
violations.push({
file: relPath,
line: lineNum,
ruleName: '规则 7 (Dialog 与 UI 反馈规范)',
message: '全项目严禁使用 useMessage(),强制统一使用全局 window.$message',
codeSnippet: trimmed
})
}
// 规则 7: 严禁使用 Callback 模式的弹窗
if (/\bon(Positive|Negative)Click\b/.test(line) && !relPath.includes('rules')) {
violations.push({
file: relPath,
line: lineNum,
ruleName: '规则 7 (禁止 Callback 模式)',
message: '严禁使用 onPositiveClick / onNegativeClick 回调,必须使用 await window.$dialog.warning(...) Promise 风格',
codeSnippet: trimmed
})
}
// 规则 4: 严禁手动导入 Vue / Pinia 常用 API (排除 import type)
if (/import\s*\{[^}]*\}\s*from\s*['"](vue|pinia)['"]/.test(line) && !/import\s+type\b/.test(line)) {
violations.push({
file: relPath,
line: lineNum,
ruleName: '规则 4 (自动导入规范)',
message: '项目已配置自动导入,严禁手动 import Vue/Pinia 常用 API(如 ref, reactive, defineStore 等)',
codeSnippet: trimmed
})
}
// 规则 5: 严禁在业务逻辑中使用 .then() 或 .catch() 链式调用
if (/\.(then|catch)\s*\(/.test(line) && !relPath.includes('node_modules') && !relPath.includes('worker')) {
violations.push({
file: relPath,
line: lineNum,
ruleName: '规则 5 (异步编程规范)',
message: '所有异步操作必须统一使用 async/await 语法,严禁使用 .then() 或 .catch() 链式调用',
codeSnippet: trimmed
})
}
// 规则 6: 严禁使用固定灰度梯度类名(如 bg-gray-50, text-gray-700, border-gray-200)
if (/\b(bg|text|border)-gray-\d+\b/.test(line) && !isDocNodeRenderer) {
violations.push({
file: relPath,
line: lineNum,
ruleName: '规则 6 (禁止硬编码灰度/普通 Tailwind 颜色类)',
message: '严禁使用固定 gray 色阶类名,必须使用系统语义类(如 bg-fill-1~4, text-color1~4, border-divider)',
codeSnippet: trimmed
})
}
// 规则 8: 禁止业务页面使用原生 n-button
if (isVue && /<n-button\b/.test(line)) {
violations.push({
file: relPath,
line: lineNum,
ruleName: '规则 8 (禁止使用 n-button)',
message: '业务页面中禁止使用原生 <n-button>,强制统一使用项目封装的 <CommonButton>',
codeSnippet: trimmed
})
}
// 规则 9: 表格操作列或定义中禁止使用 align: 'center'
if (/\balign\s*:\s*['"]center['"]/.test(line) && relPath.includes('Table')) {
violations.push({
file: relPath,
line: lineNum,
ruleName: '规则 9 (禁用表格居中对齐)',
message: '表格列定义中禁止使用 align: "center",全项目表格统一不再居中对齐',
codeSnippet: trimmed
})
}
// 规则 10: 禁用 n-date-picker,强制统一使用 CommonDatePicker
if (isVue && /<n-date-picker\b/.test(line)) {
violations.push({
file: relPath,
line: lineNum,
ruleName: '规则 10 (禁用 n-date-picker)',
message: '强制统一使用封装好的 <CommonDatePicker> 组件',
codeSnippet: trimmed
})
}
// 规则 10: 禁止使用 .split(' ')[0] 截取日期
if (/\.split\s*\(\s*['"] ['"]\s*\)\s*\[\s*0\s*\]/.test(line)) {
violations.push({
file: relPath,
line: lineNum,
ruleName: '规则 10 (强制使用全局 formatDate)',
message: '禁止使用 .split(" ")[0] 截取日期,强制统一使用全局 formatDate(value) 工具方法',
codeSnippet: trimmed
})
}
// 规则 12: 弹窗双向绑定禁止使用 v-model:show="xxx",必须统一简写为 v-model="xxx"
if (/v-model:show\s*=/.test(line)) {
violations.push({
file: relPath,
line: lineNum,
ruleName: '规则 12 (全局 Modal 封装与交互规范)',
message: '弹窗显示绑定禁止使用 v-model:show="xxx",必须统一简写为 v-model="xxx"',
codeSnippet: trimmed
})
}
// 规则 16: 禁用原生复选框 n-checkbox、单选框 n-radio
if (isVue && /<n-checkbox(-group)?\b/.test(line)) {
violations.push({
file: relPath,
line: lineNum,
ruleName: '规则 16 (禁用原生多选复选框)',
message: '禁止使用原生 <n-checkbox>,必须使用封装的 <CommonCheckbox> 或 <CommonCheckboxSingle>',
codeSnippet: trimmed
})
}
if (isVue && /<n-radio(-group)?\b/.test(line)) {
violations.push({
file: relPath,
line: lineNum,
ruleName: '规则 16 (禁用原生单选框)',
message: '禁止使用原生 <n-radio>,必须使用封装的 <CommonRadio> 或 <CommonRadioSingle>',
codeSnippet: trimmed
})
}
})
}
// ──────────────────────────────────────────────
// 结果输出与退出控制
// ──────────────────────────────────────────────
if (violations.length > 0) {
console.error(`\n${RED}${BOLD}======================================================${RESET}`)
console.error(`${RED}${BOLD}❌ Git 提交失败:检测到代码不符合 .gemini/.rules.md 规范!${RESET}`)
console.error(`${RED}${BOLD}======================================================${RESET}\n`)
violations.forEach((v, index) => {
console.error(`${YELLOW}${index + 1}. [${v.ruleName}]${RESET}`)
console.error(` ${CYAN}位置:${v.file}:${v.line}${RESET}`)
console.error(` ${RED}原因:${v.message}${RESET}`)
if (v.codeSnippet) {
console.error(` ${BOLD}代码:${v.codeSnippet}${RESET}`)
}
console.error('')
})
console.error(`${YELLOW}💡 提示:请根据上述提示修复代码,然后再执行 git commit。${RESET}\n`)
process.exit(1) // 中断提交
}
console.log(`${GREEN}✔ 已通过 .gemini/.rules.md 代码开发规范检查${RESET}`)
process.exit(0)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment