183 lines
4.8 KiB
JavaScript
183 lines
4.8 KiB
JavaScript
|
|
#!/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(); }
|