From abc6281ec05fa64053a7940751db5ae0f1cb8e8b Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 6 Jul 2026 13:19:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=9E=E8=BF=81=E5=B7=A5=E5=85=B7=EF=BC=9Ach?= =?UTF-8?q?eck-redlines=20=E5=A2=9E=E9=87=8F=E6=A8=A1=E5=BC=8F(--staged-di?= =?UTF-8?q?ff)+=20=E5=AD=98=E9=87=8F=E6=89=AB=E6=8F=8F(--scan-all)+=20setu?= =?UTF-8?q?p=20=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为「老项目回迁这套强制体系」补三件套,核心是不搞大爆炸清理: - check-redlines.js:新增 --staged-diff(只查本次新增行,老代码存量违规不阻塞, 新代码从此强制合规)与 --scan-all(可编辑正式代码 ES5 存量违规,只报不挡)。 - .githooks/pre-commit:按本地 git 配置 redlines.stagedmode 选严格(整文件)/增量(新增行)。 - setup-redlines.sh:一键激活+自检——迁移清单、node/权限、set core.hooksPath、 存量扫描;`--diff` 一键切增量模式供老项目用。 - 已测:scan-all 报本仓库 0 存量违规;增量模式放行含老违规文件的干净新增行、 拦新增违规行;严格模式整文件拦;setup 幂等且模板保持严格。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/hooks/check-redlines.js | 150 +++++++++++++++++++++----------- .githooks/pre-commit | 13 ++- setup-redlines.sh | 53 +++++++++++ 3 files changed, 163 insertions(+), 53 deletions(-) create mode 100644 setup-redlines.sh diff --git a/.claude/hooks/check-redlines.js b/.claude/hooks/check-redlines.js index 019b12c..1135584 100644 --- a/.claude/hooks/check-redlines.js +++ b/.claude/hooks/check-redlines.js @@ -1,29 +1,24 @@ #!/usr/bin/env node // 机械红线校验(只覆盖“可机器判定、低误报”的硬红线;判断类规范不在此列)。 // -// 两种运行模式: -// 1) 默认(PreToolUse 钩子):读 stdin 的工具调用 JSON,取目标路径与将写入的内容, -// 命中红线则输出 permissionDecision:"deny" 阻止本次 Write/Edit。 -// 2) --staged(git pre-commit):扫描本次暂存的文件(工具无关,人手/别的工具改也拦), -// 命中则打印并以非零退出,阻断提交。 +// 运行模式: +// (默认,PreToolUse 钩子)读 stdin 工具调用 JSON,命中即 deny 阻止 Write/Edit。 +// --staged (pre-commit·严格)扫暂存文件“整文件内容”,命中即非零退出、阻断提交。 +// --staged-diff (pre-commit·增量)只查本次“新增行”,老代码存量违规不阻塞——适合已有 +// 开发量的老项目回迁:新代码从此强制合规、旧债日后碰到再还。 +// --scan-all (报告)扫全仓库可编辑正式代码的 ES5 存量违规,只打印、退出 0、不阻断。 // -// 覆盖的红线(皆低误报): -// A. 可编辑范围(路径级,零误报):禁改平台/第三方/受限文件。 -// B. 严格 ES5(内容级,高信号):运行时 .js 里禁箭头函数/模板串/class/let/const。 +// 覆盖:A. 可编辑范围(路径级,禁改平台/vendor/00_Surface/02_Input/server 平台,app.js 例外) +// 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/ 禁止修改'; @@ -37,7 +32,6 @@ function forbiddenReason(p) { return null; } -// 是否需要做 ES5 内容检查:我们可编辑的运行时 .js。排除平台/第三方/生成物/工具/测试/文档。 function isRuntimeJs(p) { p = m(p); if (!/\.js$/.test(p)) return false; @@ -48,13 +42,9 @@ function isRuntimeJs(p) { 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, ' '); - }); + 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, "''"); @@ -64,17 +54,17 @@ function stripForScan(src) { } var ES5_RULES = [ - { re: /=>/, msg: '箭头函数(=>)——请用 function' }, - { re: /`/, msg: '模板字符串(反引号)——请用字符串拼接' }, - { re: /\bclass\s+[A-Za-z_$]/, msg: 'class 声明——请用 Object.create 继承' }, + { 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); +// 扫描已 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) }); @@ -84,46 +74,107 @@ function scanES5(src) { return out; } -// 对一个「路径 + 内容」做全部检查,返回违规文本数组(空=通过)。 +// 对「路径 + 完整内容」做检查(PreToolUse 用)。 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) { + scanStripped(stripForScan(content), null).forEach(function (v) { problems.push('【严格 ES5】第 ' + v.line + ' 行 ' + v.msg + ': ' + v.text); }); } return problems; } -// ---------- 模式二:--staged(pre-commit) ---------- +// ---------- git 辅助(--staged / --staged-diff / --scan-all 用) ---------- -function runStaged() { - var names; +function gitStaged() { 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) {} + 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)); + } + } + } } - var probs = checkOne(p, content); - if (probs.length) { all.push(' ' + name + '\n - ' + probs.join('\n - ')); } + } + 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✗ 机械红线校验未通过,提交被阻止:\n' + all.join('\n') + + process.stderr.write('\n✗ 机械红线校验未通过' + (diffMode ? '(仅查本次新增行)' : '') + + ',提交被阻止:\n' + all.join('\n') + '\n\n修正后重新提交(这些是文档记载对应过真实事故的硬红线;不要用 --no-verify 绕过)。\n\n'); process.exit(1); } process.exit(0); } -// ---------- 模式一:PreToolUse ---------- +// 存量违规报告:只看“可编辑的正式运行时 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 = []; @@ -134,8 +185,7 @@ function runPreToolUse() { 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 拼接 + 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; @@ -143,7 +193,6 @@ function runPreToolUse() { 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: { @@ -157,5 +206,8 @@ function runPreToolUse() { }); } -if (process.argv.indexOf('--staged') !== -1) runStaged(); +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(); diff --git a/.githooks/pre-commit b/.githooks/pre-commit index af113f6..8a45653 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,5 +1,10 @@ #!/bin/sh -# 机械红线校验:对本次暂存的文件做「可编辑范围 + 严格 ES5」硬检查,命中即阻断提交。 -# 工具无关——无论 Claude Code、别的编辑器还是人手改,提交都会过这道闸。 -# 启用(每个克隆一次性):git config core.hooksPath .githooks -exec node "$(git rev-parse --show-toplevel)/.claude/hooks/check-redlines.js" --staged +# 机械红线校验:对本次暂存做「可编辑范围 + 严格 ES5」硬检查,命中即阻断提交。 +# 工具无关——Claude Code、别的编辑器、人手改,提交都过这道闸。 +# 模式(本地 git 配置 redlines.stagedmode): +# 未设 / 非 diff → 严格:整文件扫描(模板等干净仓库用) +# diff → 增量:只查本次新增行(已有开发量的老项目回迁用) +# 启用(每个克隆一次性):git config core.hooksPath .githooks (或跑 setup-redlines.sh) +MODE="$(git config --get redlines.stagedmode 2>/dev/null)" +if [ "$MODE" = "diff" ]; then FLAG="--staged-diff"; else FLAG="--staged"; fi +exec node "$(git rev-parse --show-toplevel)/.claude/hooks/check-redlines.js" "$FLAG" diff --git a/setup-redlines.sh b/setup-redlines.sh new file mode 100644 index 0000000..250d212 --- /dev/null +++ b/setup-redlines.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# 一键激活 + 自检机械红线校验体系。模板全新克隆、或老项目回迁都适用。 +# +# 用法(在仓库内任意目录): +# sh setup-redlines.sh 严格模式(整文件扫描;干净仓库/模板克隆用) +# sh setup-redlines.sh --diff 增量模式(只查新增行;已有开发量的老项目回迁用, +# 老代码存量违规不阻塞,新代码从此强制合规) +# +# 它做四件事:迁移清单自检 → 依赖/权限检查 → 激活 hooksPath → 存量违规扫描(只报不挡)。 + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" +if [ -z "$ROOT" ]; then echo "✗ 当前不在 git 仓库内。"; exit 1; fi +cd "$ROOT" || exit 1 + +echo "== 1) 迁移清单自检(缺一不可,尤其 docs/ 是被遵循的内容与注入源)==" +missing=0 +for f in CLAUDE.md .claude/settings.json .claude/hooks/check-redlines.js .claude/hooks/remind-docs.js .githooks/pre-commit \ + docs/client/development-guide docs/server/development-guide docs/games/engineering; do + if [ -e "$f" ]; then echo " ✓ $f"; else echo " ✗ 缺: $f"; missing=1; fi +done +if [ "$missing" = 1 ]; then + echo " ⚠ 有缺失——请从模板补齐后再跑本脚本(缺 docs/ 会让 @import 悬空、注入为空)。" +fi + +echo "== 2) 依赖检查 ==" +if command -v node >/dev/null 2>&1; then echo " ✓ node: $(node -v)"; else + echo " ✗ 未找到 node —— PreToolUse 与 pre-commit 都会失败,请先安装 Node。"; fi + +echo "== 3) 权限 ==" +if chmod +x .githooks/pre-commit 2>/dev/null; then echo " ✓ .githooks/pre-commit 可执行"; else + echo " ! 无法 chmod(多为 Windows 文件系统,一般不影响 git 调用)"; fi + +echo "== 4) 激活提交闸 ==" +git config core.hooksPath .githooks +if [ "$1" = "--diff" ]; then + git config redlines.stagedmode diff + echo " ✓ core.hooksPath=.githooks;提交闸=增量模式(只查新增行)" +else + git config --unset redlines.stagedmode 2>/dev/null + echo " ✓ core.hooksPath=.githooks;提交闸=严格模式(整文件)" +fi +echo " git 解析到的 hooks 目录:$(git rev-parse --git-path hooks)" + +echo "== 5) 存量违规扫描(只报告,不阻塞)==" +if command -v node >/dev/null 2>&1 && [ -f .claude/hooks/check-redlines.js ]; then + node .claude/hooks/check-redlines.js --scan-all +else + echo " (缺 node 或 check-redlines.js,跳过)" +fi + +echo "" +echo "完成。日常提交起即受机械红线闸保护(严格 ES5 + 可编辑范围)。" +echo "注意:本层只管可机器判定的硬红线;判断类规范(职责单一/数据权威/房间隔离等)仍需人工与复审。"