新规hook
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
// 机械红线校验(只覆盖“可机器判定、低误报”的硬红线;判断类规范不在此列)。
|
||||
//
|
||||
// 两种运行模式:
|
||||
// 1) 默认(PreToolUse 钩子):读 stdin 的工具调用 JSON,取目标路径与将写入的内容,
|
||||
// 命中红线则输出 permissionDecision:"deny" 阻止本次 Write/Edit。
|
||||
// 2) --staged(git pre-commit):扫描本次暂存的文件(工具无关,人手/别的工具改也拦),
|
||||
// 命中则打印并以非零退出,阻断提交。
|
||||
//
|
||||
// 覆盖的红线(皆低误报):
|
||||
// A. 可编辑范围(路径级,零误报):禁改平台/第三方/受限文件。
|
||||
// B. 严格 ES5(内容级,高信号):运行时 .js 里禁箭头函数/模板串/class/let/const。
|
||||
// 本脚本自身是 Node 工具,不受被检规则约束。
|
||||
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var cp = require('child_process');
|
||||
|
||||
// ---------- 路径分类 ----------
|
||||
|
||||
function norm(p) { return String(p || '').replace(/\\/g, '/'); }
|
||||
|
||||
// 归一为「带前导斜杠」的形式,使绝对路径(G:/…/server/x)与仓库相对路径(server/x)统一可匹配。
|
||||
function m(p) { return '/' + norm(p).replace(/^\/+/, ''); }
|
||||
|
||||
// 禁改路径(命中即拦):平台代码、第三方、受限契约文件。
|
||||
function forbiddenReason(p) {
|
||||
p = m(p);
|
||||
if (/\/js\/vendor\//.test(p)) return '第三方/引擎 js/vendor/ 禁止修改';
|
||||
if (/\/js\/00_Surface\//.test(p)) return '平台代码 js/00_Surface/ 禁止修改';
|
||||
if (/\/02_SubGame_Input\.js$/.test(p)) return '受限契约文件 02_SubGame_Input.js 完全不碰';
|
||||
if (/\/server\/packet\.js$/.test(p)) return '服务端收包入口 server/packet.js 禁止修改';
|
||||
if (/\/server\/applist\.js$/.test(p)) return '服务端应用注册表 server/applist.js 禁止修改';
|
||||
if (/\/server\/class\//.test(p)) return '平台三层路由基类 server/class/ 禁止修改';
|
||||
if (/\/server\/youle\//.test(p) && !/\/server\/youle\/app\.js$/.test(p))
|
||||
return 'youle 平台代码 server/youle/ 禁止修改(仅 app.js 为接入注册点例外)';
|
||||
return null;
|
||||
}
|
||||
|
||||
// 是否需要做 ES5 内容检查:我们可编辑的运行时 .js。排除平台/第三方/生成物/工具/测试/文档。
|
||||
function isRuntimeJs(p) {
|
||||
p = m(p);
|
||||
if (!/\.js$/.test(p)) return false;
|
||||
if (/\.min\.js$/.test(p)) return false;
|
||||
if (/\/(node_modules|\.claude|docs|client\/generated)\//.test(p)) return false;
|
||||
if (/\/js\/vendor\//.test(p) || /\/js\/00_Surface\//.test(p)) return false;
|
||||
if (/(^|\/)tests?\//.test(p) || /\.(test|spec)\.js$/.test(p)) return false; // 测试可用现代语法
|
||||
return /\/client\/js\//.test(p) || /\/server\//.test(p);
|
||||
}
|
||||
|
||||
// ---------- ES5 内容扫描 ----------
|
||||
|
||||
// 去掉块注释(保留换行以维持行号),逐行去行注释与 '..'/".." 字符串(保留反引号以检测模板串)。
|
||||
function stripForScan(src) {
|
||||
src = src.replace(/\/\*[\s\S]*?\*\//g, function (m) {
|
||||
return m.replace(/[^\n]/g, ' ');
|
||||
});
|
||||
return src.split('\n').map(function (line) {
|
||||
var s = line.replace(/\/\/.*$/, '');
|
||||
s = s.replace(/'(?:\\.|[^'\\])*'/g, "''");
|
||||
s = s.replace(/"(?:\\.|[^"\\])*"/g, '""');
|
||||
return s;
|
||||
});
|
||||
}
|
||||
|
||||
var ES5_RULES = [
|
||||
{ re: /=>/, msg: '箭头函数(=>)——请用 function' },
|
||||
{ re: /`/, msg: '模板字符串(反引号)——请用字符串拼接' },
|
||||
{ re: /\bclass\s+[A-Za-z_$]/, msg: 'class 声明——请用 Object.create 继承' },
|
||||
{ re: /\b(?:let|const)\s+[A-Za-z_$[{]/, msg: 'let/const 声明——请用 var' }
|
||||
];
|
||||
|
||||
// 扫描一段源码,返回违规 [{line, msg, text}]
|
||||
function scanES5(src) {
|
||||
var lines = stripForScan(src);
|
||||
var out = [];
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
for (var r = 0; r < ES5_RULES.length; r++) {
|
||||
if (ES5_RULES[r].re.test(lines[i])) {
|
||||
out.push({ line: i + 1, msg: ES5_RULES[r].msg, text: lines[i].trim().slice(0, 80) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 对一个「路径 + 内容」做全部检查,返回违规文本数组(空=通过)。
|
||||
function checkOne(p, content) {
|
||||
var problems = [];
|
||||
var fr = forbiddenReason(p);
|
||||
if (fr) problems.push('【可编辑范围】' + fr);
|
||||
if (content != null && isRuntimeJs(p)) {
|
||||
scanES5(content).forEach(function (v) {
|
||||
problems.push('【严格 ES5】第 ' + v.line + ' 行 ' + v.msg + ': ' + v.text);
|
||||
});
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
// ---------- 模式二:--staged(pre-commit) ----------
|
||||
|
||||
function runStaged() {
|
||||
var names;
|
||||
try {
|
||||
names = cp.execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8' })
|
||||
.split('\n').map(function (s) { return s.trim(); }).filter(Boolean);
|
||||
} catch (e) { process.exit(0); }
|
||||
var all = [];
|
||||
names.forEach(function (name) {
|
||||
var p = norm(name);
|
||||
var content = null;
|
||||
if (isRuntimeJs(p)) {
|
||||
try { content = cp.execSync('git show ":' + name + '"', { encoding: 'utf8' }); } catch (e) {}
|
||||
}
|
||||
var probs = checkOne(p, content);
|
||||
if (probs.length) { all.push(' ' + name + '\n - ' + probs.join('\n - ')); }
|
||||
});
|
||||
if (all.length) {
|
||||
process.stderr.write('\n✗ 机械红线校验未通过,提交被阻止:\n' + all.join('\n') +
|
||||
'\n\n修正后重新提交(这些是文档记载对应过真实事故的硬红线;不要用 --no-verify 绕过)。\n\n');
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ---------- 模式一:PreToolUse ----------
|
||||
|
||||
function runPreToolUse() {
|
||||
var chunks = [];
|
||||
process.stdin.on('data', function (c) { chunks.push(c); });
|
||||
process.stdin.on('end', function () {
|
||||
var probs = [];
|
||||
try {
|
||||
var input = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
||||
var ti = input.tool_input || {};
|
||||
var p = norm(ti.file_path || '');
|
||||
if (!p) { process.exit(0); }
|
||||
// 取将写入的内容:Write=content;Edit=new_string;MultiEdit=各 new_string 拼接
|
||||
var content = null;
|
||||
if (typeof ti.content === 'string') content = ti.content;
|
||||
else if (typeof ti.new_string === 'string') content = ti.new_string;
|
||||
else if (Array.isArray(ti.edits))
|
||||
content = ti.edits.map(function (e) { return e && e.new_string; }).filter(function (s) { return typeof s === 'string'; }).join('\n');
|
||||
probs = checkOne(p, content);
|
||||
} catch (e) { process.exit(0); }
|
||||
|
||||
if (probs.length) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: '机械红线校验拦截(改正后重试):\n- ' + probs.join('\n- ')
|
||||
}
|
||||
}));
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv.indexOf('--staged') !== -1) runStaged();
|
||||
else runPreToolUse();
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
// PreToolUse 钩子:编辑 client/** 或 server/** 时,把「必须严格遵守的完整开发规范」注入上下文。
|
||||
//
|
||||
// 策略(对应用户要求:三套文档全部规范时刻遵守、新会话也一样):
|
||||
// - 每个文档目录,每会话「首次」编辑相关区域时全量注入其所有 .md 全文;
|
||||
// - 同会话之后再改同区域,只给简短「已注入、继续遵守」提醒,避免重复刷屏;
|
||||
// - 会话级去重用 .claude/.spec-injected/ 下的哨兵文件;SessionStart 钩子会在
|
||||
// 每个新会话开始时清空该目录 → 新会话必然重新注入。
|
||||
// - 改 docs/ 本身不打扰。
|
||||
//
|
||||
// 本脚本是「工具代码」,只在 Node(Claude Code 宿主)里跑,不受项目 ES5 红线约束。
|
||||
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
|
||||
var HOOK_DIR = __dirname; // .../.claude/hooks
|
||||
var PROJECT_DIR = path.resolve(HOOK_DIR, '..', '..'); // 项目根
|
||||
var SENTINEL_DIR = path.join(PROJECT_DIR, '.claude', '.spec-injected');
|
||||
|
||||
function slug(relDir) { return relDir.replace(/[^A-Za-z0-9]+/g, '_'); }
|
||||
|
||||
function alreadyInjected(relDir) {
|
||||
try { return fs.existsSync(path.join(SENTINEL_DIR, slug(relDir))); } catch (e) { return false; }
|
||||
}
|
||||
|
||||
function markInjected(relDir) {
|
||||
try {
|
||||
fs.mkdirSync(SENTINEL_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(SENTINEL_DIR, slug(relDir)), '1');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 读取某目录下的编号正文 .md(按文件名排序),拼成一段带分隔标题的全文。
|
||||
// 排除 README.md:它已被 CLAUDE.md 用 @import 常驻每次会话上下文,注入里再带就是重复。
|
||||
function readDirDocs(relDir) {
|
||||
var abs = path.join(PROJECT_DIR, relDir);
|
||||
var files;
|
||||
try { files = fs.readdirSync(abs); } catch (e) { return ''; }
|
||||
var out = [];
|
||||
files.filter(function (f) { return /\.md$/i.test(f) && !/^README\.md$/i.test(f); })
|
||||
.sort()
|
||||
.forEach(function (f) {
|
||||
try {
|
||||
out.push('===== ' + relDir + '/' + f + ' =====\n' +
|
||||
fs.readFileSync(path.join(abs, f), 'utf8'));
|
||||
} catch (e) {}
|
||||
});
|
||||
return out.join('\n\n');
|
||||
}
|
||||
|
||||
var chunks = [];
|
||||
process.stdin.on('data', function (c) { chunks.push(c); });
|
||||
process.stdin.on('end', function () {
|
||||
var context = '';
|
||||
try {
|
||||
var input = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
||||
var p = String((input.tool_input && input.tool_input.file_path) || '').replace(/\\/g, '/');
|
||||
|
||||
var dirs = null, area = '';
|
||||
if (/\/docs\//.test(p)) {
|
||||
dirs = null; // 改文档本身不提醒
|
||||
} else if (/\/server\//.test(p)) {
|
||||
area = '服务端';
|
||||
dirs = ['docs/server/development-guide', 'docs/games/engineering'];
|
||||
} else if (/\/client\/js\//.test(p) || /\/codes\/shared\//.test(p)) {
|
||||
area = '客户端';
|
||||
dirs = ['docs/client/development-guide', 'docs/games/engineering'];
|
||||
}
|
||||
|
||||
if (dirs) {
|
||||
var pending = [];
|
||||
for (var i = 0; i < dirs.length; i++) {
|
||||
if (!alreadyInjected(dirs[i])) { pending.push(dirs[i]); }
|
||||
}
|
||||
|
||||
if (pending.length) {
|
||||
var full = [];
|
||||
pending.forEach(function (d) {
|
||||
var txt = readDirDocs(d);
|
||||
if (txt) { full.push(txt); }
|
||||
markInjected(d);
|
||||
});
|
||||
context =
|
||||
'【必须严格遵守 · ' + area + '开发规范(本会话首次注入编号正文全文)】\n' +
|
||||
'你正在修改' + area + '代码。下面是本次及后续所有改动都必须时刻遵守的完整规范正文' +
|
||||
'(' + dirs.join('、') + ';各目录 README 的红线速查/一页纸总则已随 CLAUDE.md 常驻上下文,此处不再重复)。' +
|
||||
'逐条落实,勿凭记忆越线;dev-guide 与 engineering 冲突时以 dev-guide 硬红线为准。\n\n' +
|
||||
full.join('\n\n');
|
||||
} else {
|
||||
context =
|
||||
'【必须严格遵守 · ' + area + '开发规范】本会话已注入完整规范(' + dirs.join('、') +
|
||||
')。继续严格遵守;细节记不清就回看已注入全文或对应编号文档,不要绕过任何红线与规范。';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
context = '';
|
||||
}
|
||||
|
||||
if (context) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: context }
|
||||
}));
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
Reference in New Issue
Block a user