为「老项目回迁这套强制体系」补三件套,核心是不搞大爆炸清理: - 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>
214 lines
9.2 KiB
JavaScript
214 lines
9.2 KiB
JavaScript
#!/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();
|