bookworm-smart-assistant/hooks/check-lint.js

183 lines
4.8 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
/**
* PostToolUse Hook: 文件写入后运行 ESLint 检查
* 匹配器: Edit|Write|NotebookEdit
* 退出码: 0=通过(静默), 2=有错误(stderr反馈给Claude)
*
* v3.0 修复:
* - 使用 spawnSync + 数组参数防止 filePath shell 注入
* - 检测 eslint 本地安装状态避免 npx 远程下载
*/
const { spawnSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const readStdin = require('./lib/read-stdin.js');
const LINT_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx', '.vue'];
const ESLINT_CONFIG_FILES = [
'eslint.config.js',
'eslint.config.mjs',
'eslint.config.cjs',
'eslint.config.ts',
'eslint.config.mts',
'eslint.config.cts',
'.eslintrc.js',
'.eslintrc.cjs',
'.eslintrc.yaml',
'.eslintrc.yml',
'.eslintrc.json',
'.eslintrc',
];
function findEslintConfig(filePath) {
let dir = path.dirname(filePath);
for (let i = 0; i < 15; i++) {
for (const configFile of ESLINT_CONFIG_FILES) {
const configPath = path.join(dir, configFile);
if (fs.existsSync(configPath)) {
return dir;
}
}
// 也检查 package.json 中的 eslintConfig 字段
const pkgPath = path.join(dir, 'package.json');
if (fs.existsSync(pkgPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
if (pkg.eslintConfig) {
return dir;
}
} catch (_) {
// 忽略解析错误
}
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
/**
* 检测 eslint 是否已本地安装避免 npx 远程下载超时
*/
function isEslintInstalled(projectRoot) {
const localBin = path.join(projectRoot, 'node_modules', '.bin', 'eslint');
// Windows 上检查 .cmd 变体
return fs.existsSync(localBin) ||
fs.existsSync(localBin + '.cmd') ||
fs.existsSync(localBin + '.ps1');
}
function main() {
readStdin({ maxSize: 1024 * 1024 }).then(input => {
const filePath = (input.tool_input && input.tool_input.file_path) || '';
const ext = path.extname(filePath).toLowerCase();
// 仅处理前端/JS/TS/Vue 文件
if (!LINT_EXTENSIONS.includes(ext)) {
process.exit(0);
return;
}
// 确认文件存在
if (!fs.existsSync(filePath)) {
process.exit(0);
return;
}
// 查找 ESLint 配置
const projectRoot = findEslintConfig(filePath);
if (!projectRoot) {
process.exit(0);
return;
}
// 检测 eslint 本地安装
if (!isEslintInstalled(projectRoot)) {
process.exit(0);
return;
}
// 使用 spawnSync + 数组参数,防止 shell 注入
const npxCmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const result = spawnSync(npxCmd, [
'eslint',
'--no-error-on-unmatched-pattern',
'--format', 'compact',
filePath,
], {
cwd: projectRoot,
timeout: 8000,
encoding: 'utf8',
shell: false,
stdio: ['pipe', 'pipe', 'pipe'],
});
// ESLint 通过(退出码 0
if (result.status === 0) {
process.exit(0);
return;
}
const output = (result.stdout || '') + (result.stderr || '');
// 检查是否是 eslint 未安装或命令找不到
if (
output.includes('not found') ||
output.includes('not recognized') ||
output.includes('Cannot find module') ||
output.includes('ERR_MODULE_NOT_FOUND')
) {
process.exit(0);
return;
}
// compact 格式: /path/file.ts: line X, col Y, Error - message (rule-name)
const lines = output.split('\n').filter(line => line.trim());
// 仅提取 Error 级别(忽略 Warning
const errorLines = lines.filter(line =>
/:\s*line\s+\d+.*Error\s+-\s+/.test(line)
);
if (errorLines.length === 0) {
process.exit(0);
return;
}
// 格式化错误信息
const fileName = path.basename(filePath);
const maxDisplay = 10;
const displayErrors = errorLines.slice(0, maxDisplay);
const summary = displayErrors
.map(line => {
const match = line.match(/:\s*line\s+(\d+),\s*col\s+(\d+),\s*Error\s+-\s+(.+)/);
if (match) {
return ` L${match[1]}:${match[2]} - ${match[3]}`;
}
return ` ${line.trim()}`;
})
.join('\n');
const message = [
`[ESLint] 在 ${fileName} 中发现 ${errorLines.length} 个错误:`,
summary,
errorLines.length > maxDisplay ? ` ... 还有 ${errorLines.length - maxDisplay} 个错误` : '',
'',
'请修复以上 ESLint 错误。',
].filter(Boolean).join('\n');
process.stderr.write(JSON.stringify({
continue: true,
suppressOutput: false,
systemMessage: message,
}));
process.exit(2);
}).catch(() => process.exit(0));
}
if (require.main === module) { main(); }