Files
erqiwang_youle/.claude/hooks/check-redlines.js
T
2026-07-06 17:16:05 +08:00

162 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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();