Files
youle_framework/.claude/hooks/check-redlines.js
T
joywayerandClaude Opus 4.8 abc6281ec0 回迁工具: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>
2026-07-06 13:19:27 +08:00

214 lines
9.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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();