回迁工具:check-redlines 增量模式(--staged-diff)+ 存量扫描(--scan-all)+ setup 脚本

为「老项目回迁这套强制体系」补三件套,核心是不搞大爆炸清理:

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 13:19:27 +08:00
co-authored by Claude Opus 4.8
parent c67a9f9a26
commit abc6281ec0
3 changed files with 163 additions and 53 deletions
+97 -45
View File
@@ -1,29 +1,24 @@
#!/usr/bin/env node #!/usr/bin/env node
// 机械红线校验(只覆盖“可机器判定、低误报”的硬红线;判断类规范不在此列)。 // 机械红线校验(只覆盖“可机器判定、低误报”的硬红线;判断类规范不在此列)。
// //
// 两种运行模式: // 运行模式:
// 1) 默认(PreToolUse 钩子):读 stdin 的工具调用 JSON,取目标路径与将写入的内容, // (默认,PreToolUse 钩子)读 stdin 工具调用 JSON,命中即 deny 阻止 Write/Edit。
// 命中红线则输出 permissionDecision:"deny" 阻止本次 Write/Edit。 // --staged (pre-commit·严格)扫暂存文件“整文件内容”,命中即非零退出、阻断提交。
// 2) --staged(git pre-commit):扫描本次暂存的文件(工具无关,人手/别的工具改也拦), // --staged-diff (pre-commit·增量)只查本次“新增行”,老代码存量违规不阻塞——适合已有
// 命中则打印并以非零退出,阻断提交。 // 开发量的老项目回迁:新代码从此强制合规、旧债日后碰到再还。
// --scan-all (报告)扫全仓库可编辑正式代码的 ES5 存量违规,只打印、退出 0、不阻断。
// //
// 覆盖的红线(皆低误报): // 覆盖:A. 可编辑范围(路径级,禁改平台/vendor/00_Surface/02_Input/server 平台,app.js 例外)
// A. 可编辑范围(路径级,零误报):禁改平台/第三方/受限文件。 // B. 严格 ES5(运行时正式 .js 禁箭头/模板串/class/let/const;测试脚本放行现代语法)
// B. 严格 ES5(内容级,高信号):运行时 .js 里禁箭头函数/模板串/class/let/const。
// 本脚本自身是 Node 工具,不受被检规则约束。 // 本脚本自身是 Node 工具,不受被检规则约束。
var fs = require('fs'); var fs = require('fs');
var path = require('path');
var cp = require('child_process'); var cp = require('child_process');
// ---------- 路径分类 ----------
function norm(p) { return String(p || '').replace(/\\/g, '/'); } function norm(p) { return String(p || '').replace(/\\/g, '/'); }
// 归一为带前导斜杠,使绝对路径与仓库相对路径统一可匹配。
// 归一为「带前导斜杠」的形式,使绝对路径(G:/…/server/x)与仓库相对路径(server/x)统一可匹配。
function m(p) { return '/' + norm(p).replace(/^\/+/, ''); } function m(p) { return '/' + norm(p).replace(/^\/+/, ''); }
// 禁改路径(命中即拦):平台代码、第三方、受限契约文件。
function forbiddenReason(p) { function forbiddenReason(p) {
p = m(p); p = m(p);
if (/\/js\/vendor\//.test(p)) return '第三方/引擎 js/vendor/ 禁止修改'; if (/\/js\/vendor\//.test(p)) return '第三方/引擎 js/vendor/ 禁止修改';
@@ -37,7 +32,6 @@ function forbiddenReason(p) {
return null; return null;
} }
// 是否需要做 ES5 内容检查:我们可编辑的运行时 .js。排除平台/第三方/生成物/工具/测试/文档。
function isRuntimeJs(p) { function isRuntimeJs(p) {
p = m(p); p = m(p);
if (!/\.js$/.test(p)) return false; if (!/\.js$/.test(p)) return false;
@@ -48,13 +42,9 @@ function isRuntimeJs(p) {
return /\/client\/js\//.test(p) || /\/server\//.test(p); return /\/client\/js\//.test(p) || /\/server\//.test(p);
} }
// ---------- ES5 内容扫描 ---------- // 去块注释(保留换行以维持行号),逐行去行注释与 '..'/".." 字符串(保留反引号以检测模板串)。
// 去掉块注释(保留换行以维持行号),逐行去行注释与 '..'/".." 字符串(保留反引号以检测模板串)。
function stripForScan(src) { function stripForScan(src) {
src = src.replace(/\/\*[\s\S]*?\*\//g, function (m) { src = src.replace(/\/\*[\s\S]*?\*\//g, function (mm) { return mm.replace(/[^\n]/g, ' '); });
return m.replace(/[^\n]/g, ' ');
});
return src.split('\n').map(function (line) { return src.split('\n').map(function (line) {
var s = line.replace(/\/\/.*$/, ''); var s = line.replace(/\/\/.*$/, '');
s = s.replace(/'(?:\\.|[^'\\])*'/g, "''"); s = s.replace(/'(?:\\.|[^'\\])*'/g, "''");
@@ -70,11 +60,11 @@ var ES5_RULES = [
{ re: /\b(?:let|const)\s+[A-Za-z_$[{]/, msg: 'let/const 声明——请用 var' } { re: /\b(?:let|const)\s+[A-Za-z_$[{]/, msg: 'let/const 声明——请用 var' }
]; ];
// 扫描一段源码,返回违规 [{line, msg, text}] // 扫描已 strip 的行数组;onlyLines 为 Set 时只查这些行号(1 基),null=全查。
function scanES5(src) { function scanStripped(lines, onlyLines) {
var lines = stripForScan(src);
var out = []; var out = [];
for (var i = 0; i < lines.length; i++) { for (var i = 0; i < lines.length; i++) {
if (onlyLines && !onlyLines.has(i + 1)) continue;
for (var r = 0; r < ES5_RULES.length; r++) { for (var r = 0; r < ES5_RULES.length; r++) {
if (ES5_RULES[r].re.test(lines[i])) { 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) }); 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; return out;
} }
// 对一个「路径 + 内容」做全部检查,返回违规文本数组(空=通过)。 // 对「路径 + 完整内容」做检查(PreToolUse 用)。
function checkOne(p, content) { function checkOne(p, content) {
var problems = []; var problems = [];
var fr = forbiddenReason(p); var fr = forbiddenReason(p);
if (fr) problems.push('【可编辑范围】' + fr); if (fr) problems.push('【可编辑范围】' + fr);
if (content != null && isRuntimeJs(p)) { 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); problems.push('【严格 ES5】第 ' + v.line + ' 行 ' + v.msg + ': ' + v.text);
}); });
} }
return problems; return problems;
} }
// ---------- 模式二:--staged(pre-commit) ---------- // ---------- git 辅助(--staged / --staged-diff / --scan-all 用) ----------
function runStaged() { function gitStaged() {
var names;
try { try {
names = cp.execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8' }) return cp.execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8' })
.split('\n').map(function (s) { return s.trim(); }).filter(Boolean); .split('\n').map(function (s) { return s.trim(); }).filter(Boolean);
} catch (e) { process.exit(0); } } catch (e) { return []; }
var all = []; }
names.forEach(function (name) { function stagedContent(name) {
var p = norm(name); try { return cp.execSync('git show ":' + name + '"', { encoding: 'utf8' }); } catch (e) { return null; }
var content = null; }
if (isRuntimeJs(p)) { // 本次暂存 diff 里“新增行”的新文件行号集合。
try { content = cp.execSync('git show ":' + name + '"', { encoding: 'utf8' }); } catch (e) {} 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) { 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'); '\n\n修正后重新提交(这些是文档记载对应过真实事故的硬红线;不要用 --no-verify 绕过)。\n\n');
process.exit(1); process.exit(1);
} }
process.exit(0); 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() { function runPreToolUse() {
var chunks = []; var chunks = [];
@@ -134,8 +185,7 @@ function runPreToolUse() {
var input = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); var input = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
var ti = input.tool_input || {}; var ti = input.tool_input || {};
var p = norm(ti.file_path || ''); var p = norm(ti.file_path || '');
if (!p) { process.exit(0); } if (!p) process.exit(0);
// 取将写入的内容:Write=content;Edit=new_string;MultiEdit=各 new_string 拼接
var content = null; var content = null;
if (typeof ti.content === 'string') content = ti.content; if (typeof ti.content === 'string') content = ti.content;
else if (typeof ti.new_string === 'string') content = ti.new_string; 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'); content = ti.edits.map(function (e) { return e && e.new_string; }).filter(function (s) { return typeof s === 'string'; }).join('\n');
probs = checkOne(p, content); probs = checkOne(p, content);
} catch (e) { process.exit(0); } } catch (e) { process.exit(0); }
if (probs.length) { if (probs.length) {
process.stdout.write(JSON.stringify({ process.stdout.write(JSON.stringify({
hookSpecificOutput: { 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(); else runPreToolUse();
+9 -4
View File
@@ -1,5 +1,10 @@
#!/bin/sh #!/bin/sh
# 机械红线校验:对本次暂存的文件做「可编辑范围 + 严格 ES5」硬检查,命中即阻断提交。 # 机械红线校验:对本次暂存做「可编辑范围 + 严格 ES5」硬检查,命中即阻断提交。
# 工具无关——无论 Claude Code、别的编辑器还是人手改,提交都会过这道闸。 # 工具无关——Claude Code、别的编辑器、人手改,提交都过这道闸。
# 启用(每个克隆一次性):git config core.hooksPath .githooks # 模式(本地 git 配置 redlines.stagedmode):
exec node "$(git rev-parse --show-toplevel)/.claude/hooks/check-redlines.js" --staged # 未设 / 非 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"
+53
View File
@@ -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 "注意:本层只管可机器判定的硬红线;判断类规范(职责单一/数据权威/房间隔离等)仍需人工与复审。"