bookworm-smart-assistant/hooks/suggest-tests.js

185 lines
5.0 KiB
JavaScript

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