From 1d9bc049f190be3009362547904b3892ff533ec7 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 6 Jul 2026 09:25:59 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A1=AC=E7=BA=A6=E6=9D=9F=EF=BC=9A=E6=9C=BA?= =?UTF-8?q?=E6=A2=B0=E7=BA=A2=E7=BA=BF=E6=A0=A1=E9=AA=8C=EF=BC=88PreToolUs?= =?UTF-8?q?e=20=E9=98=BB=E6=96=AD=20+=20git=20pre-commit=EF=BC=89+=20PostC?= =?UTF-8?q?ompact=20=E9=87=8D=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把可机器判定的硬红线从"投喂"升级为"硬拦截",并补上事后/工具无关/压缩三个缺口: - .claude/hooks/check-redlines.js:校验「可编辑范围」(路径级,禁改平台/ vendor/00_Surface/02_Input/server 平台,app.js 例外) 与「严格 ES5」(运行时 .js 禁箭头/模板串/class/let/const,去注释与字符串后高信号匹配)。双模式: PreToolUse 读工具入参、命中即 deny 阻止写入;--staged 扫暂存文件、命中即 非零退出阻断提交。 - settings.json:PreToolUse 增挂 check-redlines(在 remind 之前);新增 PostCompact 钩子清空 .spec-injected,使压缩后下次编辑重新全量注入规范。 - .githooks/pre-commit + core.hooksPath=.githooks:提交边界硬闸,工具无关 (Claude Code/别的编辑器/人手改都过)。 - 已校准:81 个现有运行时 js 零 ES5 误报(可编辑的 framework 25 文件全过), 平台文件正确判为禁改;真违规(箭头/模板/const/class/改 vendor/改 class)全拦、 合法 ES5 与注释里的 => 全放行;pre-commit 实测拦住 ES5 违规提交。 判断类规范(职责单一/数据权威设计/房间隔离)机器判不了,不在本层,仍靠投喂 +(可选)改后复审 agent。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/hooks/check-redlines.js | 161 ++++++++++++++++++++++++++++++++ .claude/settings.json | 16 ++++ .githooks/pre-commit | 5 + 3 files changed, 182 insertions(+) create mode 100644 .claude/hooks/check-redlines.js create mode 100644 .githooks/pre-commit diff --git a/.claude/hooks/check-redlines.js b/.claude/hooks/check-redlines.js new file mode 100644 index 0000000..019b12c --- /dev/null +++ b/.claude/hooks/check-redlines.js @@ -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(); diff --git a/.claude/settings.json b/.claude/settings.json index 89e4e31..0b3da80 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,10 +10,26 @@ ] } ], + "PostCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "rm -rf \"$CLAUDE_PROJECT_DIR/.claude/.spec-injected\"" + } + ] + } + ], "PreToolUse": [ { "matcher": "Edit|Write|MultiEdit", "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/check-redlines.js\"", + "timeout": 10, + "statusMessage": "校验机械红线…" + }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/remind-docs.js\"", diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..af113f6 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +# 机械红线校验:对本次暂存的文件做「可编辑范围 + 严格 ES5」硬检查,命中即阻断提交。 +# 工具无关——无论 Claude Code、别的编辑器还是人手改,提交都会过这道闸。 +# 启用(每个克隆一次性):git config core.hooksPath .githooks +exec node "$(git rev-parse --show-toplevel)/.claude/hooks/check-redlines.js" --staged