#!/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(); }