#!/usr/bin/env node // 机械红线校验(只覆盖“可机器判定、低误报”的硬红线;判断类规范不在此列)。 // // 运行模式: // (默认,PreToolUse 钩子)读 stdin 工具调用 JSON,命中即 deny 阻止 Write/Edit。 // --staged (pre-commit·严格)扫暂存文件“整文件内容”,命中即非零退出、阻断提交。 // --staged-diff (pre-commit·增量)只查本次“新增行”,老代码存量违规不阻塞——适合已有 // 开发量的老项目回迁:新代码从此强制合规、旧债日后碰到再还。 // --scan-all (报告)扫全仓库可编辑正式代码的 ES5 存量违规,只打印、退出 0、不阻断。 // // 覆盖:A. 可编辑范围(路径级,禁改平台/vendor/00_Surface/02_Input/server 平台,app.js 例外) // B. 严格 ES5(运行时正式 .js 禁箭头/模板串/class/let/const;测试脚本放行现代语法) // 本脚本自身是 Node 工具,不受被检规则约束。 var fs = require('fs'); var cp = require('child_process'); function norm(p) { return String(p || '').replace(/\\/g, '/'); } // 归一为带前导斜杠,使绝对路径与仓库相对路径统一可匹配。 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; } 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); } // 去块注释(保留换行以维持行号),逐行去行注释与 '..'/".." 字符串(保留反引号以检测模板串)。 function stripForScan(src) { src = src.replace(/\/\*[\s\S]*?\*\//g, function (mm) { return mm.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' } ]; // 扫描已 strip 的行数组;onlyLines 为 Set 时只查这些行号(1 基),null=全查。 function scanStripped(lines, onlyLines) { var out = []; for (var i = 0; i < lines.length; i++) { if (onlyLines && !onlyLines.has(i + 1)) continue; 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; } // 对「路径 + 完整内容」做检查(PreToolUse 用)。 function checkOne(p, content) { var problems = []; var fr = forbiddenReason(p); if (fr) problems.push('【可编辑范围】' + fr); if (content != null && isRuntimeJs(p)) { scanStripped(stripForScan(content), null).forEach(function (v) { problems.push('【严格 ES5】第 ' + v.line + ' 行 ' + v.msg + ': ' + v.text); }); } return problems; } // ---------- git 辅助(--staged / --staged-diff / --scan-all 用) ---------- function gitStaged() { try { return cp.execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8' }) .split('\n').map(function (s) { return s.trim(); }).filter(Boolean); } catch (e) { return []; } } function stagedContent(name) { try { return cp.execSync('git show ":' + name + '"', { encoding: 'utf8' }); } catch (e) { return null; } } // 本次暂存 diff 里“新增行”的新文件行号集合。 function addedLines(name) { var set = {}, has = function (n) { return set[n] === true; }; var out; try { out = cp.execSync('git diff --cached -U0 -- "' + name + '"', { encoding: 'utf8' }); } catch (e) { return { has: function () { return false; } }; } var cur = 0; out.split('\n').forEach(function (l) { var mh = l.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); if (mh) { cur = parseInt(mh[1], 10); return; } if (l.charAt(0) === '+' && l.substr(0, 3) !== '+++') { set[cur] = true; cur++; return; } // '-' 删除行不推进新文件行号;'\' 无换行标记忽略 }); return { has: has }; } function checkStagedFile(name, diffMode) { var probs = []; var fr = forbiddenReason(name); if (fr) probs.push('【可编辑范围】' + fr); if (isRuntimeJs(name)) { var content = stagedContent(name); if (content != null) { var lines = stripForScan(content); var only = diffMode ? addedLines(name) : null; for (var i = 0; i < lines.length; i++) { if (only && !only.has(i + 1)) continue; for (var r = 0; r < ES5_RULES.length; r++) { if (ES5_RULES[r].re.test(lines[i])) { probs.push('【严格 ES5】第 ' + (i + 1) + ' 行 ' + ES5_RULES[r].msg + ': ' + lines[i].trim().slice(0, 80)); } } } } } return probs; } function runStaged(diffMode) { var all = []; gitStaged().forEach(function (name) { var probs = checkStagedFile(norm(name), diffMode); if (probs.length) all.push(' ' + name + '\n - ' + probs.join('\n - ')); }); if (all.length) { process.stderr.write('\n✗ 机械红线校验未通过' + (diffMode ? '(仅查本次新增行)' : '') + ',提交被阻止:\n' + all.join('\n') + '\n\n修正后重新提交(这些是文档记载对应过真实事故的硬红线;不要用 --no-verify 绕过)。\n\n'); process.exit(1); } process.exit(0); } // 存量违规报告:只看“可编辑的正式运行时 js”的 ES5 违规;平台文件(本就禁改)不计为存量债。 function scanAll() { var files; try { files = cp.execSync('git ls-files', { encoding: 'utf8' }).split('\n').map(function (s) { return s.trim(); }).filter(Boolean); } catch (e) { console.log('(非 git 仓库,跳过存量扫描)'); process.exit(0); } var scanned = 0, bad = 0, total = 0, rows = []; files.forEach(function (name) { var p = norm(name); if (!isRuntimeJs(p) || forbiddenReason(p)) return; var content = null; try { content = fs.readFileSync(p, 'utf8'); } catch (e) { return; } scanned++; var v = scanStripped(stripForScan(content), null); if (v.length) { bad++; total += v.length; rows.push(' ' + name + ' (' + v.length + ' 处:第 ' + v.map(function (x) { return x.line; }).join('/') + ' 行)'); } }); console.log('存量 ES5 违规扫描(可编辑正式代码,' + scanned + ' 文件):'); if (bad) { console.log(' 发现 ' + bad + ' 个文件、' + total + ' 处违规(不阻塞,日后碰到逐步还):'); console.log(rows.join('\n')); } else { console.log(' ✓ 可编辑正式代码零 ES5 存量违规。'); } process.exit(0); } 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); 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); }); } var argv = process.argv; if (argv.indexOf('--staged-diff') !== -1) runStaged(true); else if (argv.indexOf('--staged') !== -1) runStaged(false); else if (argv.indexOf('--scan-all') !== -1) scanAll(); else runPreToolUse();