#!/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();