#!/usr/bin/env node /** * PostToolUse Hook: 源代码修改后提醒运行测试 * 匹配器: Edit|Write|NotebookEdit * 触发: 修改了源代码文件(非测试文件)时 * 退出码: 0=静默通过, 2=提醒(非阻断,continue=true) * * 行为: * - 修改测试文件本身 → 静默 * - 修改配置/文档文件 → 静默 * - 修改源代码文件 → 检测对应测试文件是否存在 → 提醒运行 */ const path = require('path'); const fs = require('fs'); // 源代码文件扩展名 const SOURCE_EXTENSIONS = [ '.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs' ]; // 测试文件模式 (这些文件本身是测试,修改时不提醒) const TEST_PATTERNS = [ /\.test\.[tj]sx?$/, /\.spec\.[tj]sx?$/, /__tests__\//, /\/tests?\//, /test_[^/]+\.py$/, /_test\.go$/, /\.e2e\./, /\.integration\./ ]; // 配置/文档文件 (不提醒) const SKIP_PATTERNS = [ /\.md$/, /\.json$/, /\.yaml$/, /\.yml$/, /\.toml$/, /\.env($|\.)/, /\.css$/, /\.scss$/, /\.html$/, /\.svg$/, /\.png$/, /\.jpg$/, /Dockerfile/, /docker-compose/, /\.gitignore/, /\.eslintrc/, /tsconfig/, /package\.json/ ]; const KNOWN_TEST_COMMANDS = { '.ts': 'pnpm vitest run', '.tsx': 'pnpm vitest run', '.js': 'pnpm vitest run', '.jsx': 'pnpm vitest run', '.py': 'python -m pytest', '.go': 'go test ./...', '.rs': 'cargo test' }; const readStdin = require('./lib/read-stdin.js'); function findTestFile(filePath) { const dir = path.dirname(filePath); const ext = path.extname(filePath); const base = path.basename(filePath, ext); // 常见测试文件路径模式 const candidates = [ // 同目录: foo.test.ts path.join(dir, `${base}.test${ext}`), path.join(dir, `${base}.spec${ext}`), // __tests__ 子目录 path.join(dir, '__tests__', `${base}.test${ext}`), path.join(dir, '__tests__', `${base}.spec${ext}`), // tests 兄弟目录 path.join(dir, '..', 'tests', `${base}.test${ext}`), path.join(dir, '..', 'tests', `test_${base}${ext}`), // Python 风格 path.join(dir, `test_${base}${ext}`), // Go 风格 path.join(dir, `${base}_test${ext}`) ]; for (const candidate of candidates) { try { if (fs.existsSync(candidate)) { return candidate; } } catch { // 文件系统错误,跳过 } } return null; } function main() { readStdin({ maxSize: 1024 * 1024 }).then(input => { const toolName = input.tool_name; if (toolName !== 'Edit' && toolName !== 'Write' && toolName !== 'NotebookEdit') { process.exit(0); } const filePath = (input.tool_input && (input.tool_input.file_path || input.tool_input.filePath || input.tool_input.notebook_path)) || ''; if (!filePath) { process.exit(0); } const ext = path.extname(filePath).toLowerCase(); // 不是源代码文件 → 静默 if (!SOURCE_EXTENSIONS.includes(ext)) { process.exit(0); } // 是测试文件本身 → 静默 if (TEST_PATTERNS.some(p => p.test(filePath))) { process.exit(0); } // 是配置/文档 → 静默 if (SKIP_PATTERNS.some(p => p.test(filePath))) { process.exit(0); } // 查找对应测试文件 const testFile = findTestFile(filePath); const testCmd = KNOWN_TEST_COMMANDS[ext] || 'test'; const baseName = path.basename(filePath); let suggestion; if (testFile) { const relTest = path.basename(testFile); suggestion = `[test-hint] 已修改 \`${baseName}\`,对应测试文件 \`${relTest}\` 存在。建议后续运行: \`${testCmd} ${path.basename(testFile)}\``; } else { suggestion = `[test-hint] 已修改 \`${baseName}\`,未找到对应测试文件。如果是核心逻辑,建议补充测试。`; } const output = { continue: true, suppressOutput: false, systemMessage: suggestion }; process.stderr.write(JSON.stringify(output)); process.exit(2); }).catch(() => process.exit(0)); } /** * 内联检查函数 (供 post-edit-dispatcher 委托调用) */ function inlineCheck(filePath, ext, toolName) { try { if (!SOURCE_EXTENSIONS.includes(ext)) return null; if (TEST_PATTERNS.some(p => p.test(filePath))) return null; if (SKIP_PATTERNS.some(p => p.test(filePath))) return null; const testFile = findTestFile(filePath); const testCmd = KNOWN_TEST_COMMANDS[ext] || 'test'; const baseName = path.basename(filePath); if (testFile) { const relTest = path.basename(testFile); return `[test-hint] 已修改 \`${baseName}\`,对应测试文件 \`${relTest}\` 存在。建议运行: \`${testCmd} ${path.basename(testFile)}\``; } return `[test-hint] 已修改 \`${baseName}\`,未找到对应测试文件。如果是核心逻辑,建议补充测试。`; } catch { return null; } } // 模块导出 (供 post-edit-dispatcher 委托调用) if (typeof module !== 'undefined') { module.exports = { findTestFile, inlineCheck, SOURCE_EXTENSIONS, TEST_PATTERNS, SKIP_PATTERNS }; } if (require.main === module) { main(); }