#!/usr/bin/env node /** * 配置自验证器 (Config Validator) * * 验证 settings.json 的钩子/MCP 配置与实际文件的一致性。 * 发现问题时可选 Level-1 自愈 (--fix 模式)。 * * 用法: * node scripts/config-validator.js # 验证 (只报告) * node scripts/config-validator.js --fix # 验证 + 自动修复 * node scripts/config-validator.js --json # JSON 输出 */ const fs = require('fs'); const path = require('path'); const detectClaudeRoot = () => require('./paths.config.js').PATHS.root; const CLAUDE_ROOT = detectClaudeRoot(); const SETTINGS_FILE = path.join(CLAUDE_ROOT, 'settings.json'); // Windows 路径 → WSL 路径转换 (兼容两种环境) function toNativePath(p) { if (process.platform !== 'win32' && /^[A-Z]:[/\\]/i.test(p)) { return p.replace(/^([A-Z]):[/\\]/i, (_, d) => `/mnt/${d.toLowerCase()}/`).replace(/\\/g, '/'); } return p; } const FIX_MODE = process.argv.includes('--fix'); const JSON_MODE = process.argv.includes('--json'); // === 验证结果收集 === let findings = []; // { level: 'error'|'warn', category, message, fix? } function addFinding(level, category, message, fix) { findings.push({ level, category, message, fix: fix || null }); } function resetFindings() { findings = []; return findings; } // === 1. 读取 settings.json === function loadSettings() { if (!fs.existsSync(SETTINGS_FILE)) { addFinding('error', 'settings', 'settings.json 不存在'); return null; } try { return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')); } catch (e) { addFinding('error', 'settings', 'settings.json 解析失败: ' + e.message); return null; } } // === 2. 验证钩子文件存在性 === function validateHooks(settings) { const hookEntries = settings.hooks || {}; const allPaths = new Set(); // 遍历所有事件类型 (含 v5.2 新增的 UserPromptSubmit/SubagentStart/Stop) const allPhases = ['PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'SubagentStart', 'Stop']; for (const phase of allPhases) { const groups = hookEntries[phase] || []; for (const group of groups) { for (const hook of (group.hooks || [])) { if (hook.type === 'command' && hook.command) { // 提取命令中的文件路径 const match = hook.command.match(/node\s+["']?([^"'\s]+\.js)["']?/); if (match) { const hookPath = match[1]; const nativeHookPath = toNativePath(hookPath); allPaths.add(path.normalize(nativeHookPath)); // 检查文件是否存在 (兼容 Windows/WSL 路径 + .disabled 惯例) const disabledPath = nativeHookPath + '.disabled'; if (!fs.existsSync(nativeHookPath) && fs.existsSync(disabledPath)) { // .disabled 文件视为有意禁用,降级为 info 而非 error addFinding('info', 'hook-disabled', `${phase} 钩子已禁用: ${path.basename(hookPath)}.disabled`, `如需恢复,去掉 .disabled 后缀`); } else if (!fs.existsSync(nativeHookPath)) { addFinding('error', 'hook-missing', `${phase} 钩子文件不存在: ${hookPath}`, `移除对 ${path.basename(hookPath)} 的引用`); } } } // 检查 timeout 合理性 if (hook.timeout && (hook.timeout < 500 || hook.timeout > 30000)) { addFinding('warn', 'hook-timeout', `${phase} 钩子超时值异常: ${hook.timeout}ms (建议 1000-15000)`); } } // 检查 matcher 不为空 (仅 PreToolUse/PostToolUse 需要 matcher) if ((phase === 'PreToolUse' || phase === 'PostToolUse') && (!group.matcher || group.matcher.trim() === '')) { addFinding('warn', 'hook-matcher', `${phase} 有空 matcher`); } } } // 检查钩子目录中有无未注册的钩子 const hooksDir = path.join(CLAUDE_ROOT, 'hooks'); if (fs.existsSync(hooksDir)) { const hookFiles = fs.readdirSync(hooksDir) .filter(f => f.endsWith('.js') && !f.startsWith('__')); // 识别通过 dispatcher 间接注册的子钩子 (v5.3) // 子钩子 (由 dispatcher 内部调用) 和备用钩子 (by-design 未注册) 均跳过 const SUB_HOOKS = new Set([ 'check-typescript.js', 'check-lint.js', 'suggest-tests.js', 'drift-detector.js', 'integrity-check.js', ]); const BACKUP_HOOKS = new Set([ 'block-dangerous-commands.js', 'code-quality-gate.js', 'commit-message-lint.js', 'constitution-guard.js', 'constitution-session-report.js', 'edit-precheck-dispatcher.js', 'log-rotator.js', 'post-edit-quality-check.js', 'route-auditor.js', 'route-interceptor-bundle.js', 'security-startup-guard.js', ]); for (const file of hookFiles) { const fullPath = path.join(hooksDir, file); if (!allPaths.has(fullPath) && !SUB_HOOKS.has(file) && !BACKUP_HOOKS.has(file)) { addFinding('warn', 'hook-unregistered', `钩子文件 ${file} 存在但未在 settings.json 中注册`); } } } return allPaths; } // === 3. 验证 MCP 服务器配置 === function validateMcpServers(settings) { const mcpServers = settings.mcpServers || {}; for (const [name, config] of Object.entries(mcpServers)) { // HTTP remote MCPs: 验证 url 而非 command if (config.type === 'http') { if (!config.url) { addFinding('error', 'mcp-config', `MCP ${name}: HTTP 类型缺少 url 字段`); } else if (!config.url.startsWith('https://')) { addFinding('warn', 'mcp-config', `MCP ${name}: HTTP MCP 应使用 HTTPS`); } continue; } // 检查 command 字段 if (!config.command) { addFinding('error', 'mcp-config', `MCP ${name}: 缺少 command 字段`); continue; } // 如果 command 是本地文件路径,检查是否存在 // 豁免系统内置命令 (cmd.exe/powershell.exe 等在 WSL 下通过 interop 可用) const sysBuiltins = ['cmd.exe', 'powershell.exe', 'pwsh.exe', 'wsl.exe']; const cmdBasename = path.basename(config.command).toLowerCase(); if (config.command.match(/\.(py|js|sh|exe)$/i) && !sysBuiltins.includes(cmdBasename)) { // 本地文件命令 if (!fs.existsSync(config.command)) { // 也检查 Windows 路径 (WSL 下) const wslPath = config.command.replace(/^([A-Z]):\\/, (_, d) => `/mnt/${d.toLowerCase()}/`).replace(/\\/g, '/'); if (!fs.existsSync(wslPath)) { addFinding('warn', 'mcp-command', `MCP ${name}: 命令文件可能不存在 (${config.command})`); } } } // 检查 args 中的本地文件 if (config.args) { for (const arg of config.args) { if (arg.match(/\.(py|js|sh)$/i) && !arg.startsWith('-') && !arg.startsWith('@')) { const nativeArg = toNativePath(arg); if (!fs.existsSync(nativeArg)) { addFinding('warn', 'mcp-args', `MCP ${name}: 参数文件可能不存在 (${arg})`); } } } } // 检查 type if (!config.type) { addFinding('warn', 'mcp-config', `MCP ${name}: 缺少 type 字段`); } } } // === 4. 验证 rules/*.json === function validateRules() { const rulesDir = path.join(CLAUDE_ROOT, 'hooks', 'rules'); if (!fs.existsSync(rulesDir)) { addFinding('warn', 'rules-dir', 'hooks/rules/ 目录不存在'); return; } const ruleFiles = fs.readdirSync(rulesDir).filter(f => f.endsWith('.json') && f !== 'rules-compiled.json'); if (ruleFiles.length === 0) { addFinding('warn', 'rules-empty', 'hooks/rules/ 中无规则文件'); return; } for (const file of ruleFiles) { const filePath = path.join(rulesDir, file); try { const rules = JSON.parse(fs.readFileSync(filePath, 'utf8')); // mcp-tool-classification.json 使用不同结构 (readonlyPatterns/dangerousPatterns) if (file === 'mcp-tool-classification.json') { if (!Array.isArray(rules.readonlyPatterns) && !Array.isArray(rules.dangerousPatterns)) { addFinding('error', 'rules-format', `${file}: 缺少 readonlyPatterns 或 dangerousPatterns 数组`); } continue; } if (!Array.isArray(rules.patterns)) { addFinding('error', 'rules-format', `${file}: 缺少 patterns 数组`); continue; } // 验证每条正则可编译 for (const p of rules.patterns) { if (!p.regex) { addFinding('warn', 'rules-format', `${file}: 规则缺少 regex 字段`); continue; } try { new RegExp(p.regex, p.flags || 'i'); } catch (e) { addFinding('error', 'rules-regex', `${file}: 正则编译失败 "${p.regex}" - ${e.message}`); } } } catch (e) { addFinding('error', 'rules-parse', `${file}: JSON 解析失败 - ${e.message}`); } } } // === 5. 验证 checksums.json === function validateChecksums() { const checksumFile = path.join(CLAUDE_ROOT, 'hooks', 'checksums.json'); if (!fs.existsSync(checksumFile)) { addFinding('warn', 'checksums', 'checksums.json 不存在 (运行 node hooks/integrity-check.js --generate 生成)', 'node hooks/integrity-check.js --generate'); return; } try { const checksums = JSON.parse(fs.readFileSync(checksumFile, 'utf8')); const hooksDir = path.join(CLAUDE_ROOT, 'hooks'); const hookFiles = fs.readdirSync(hooksDir) .filter(f => f.endsWith('.js') && !f.startsWith('__')); // 检查是否覆盖了所有钩子 for (const file of hookFiles) { if (file === 'integrity-check.js') continue; // 自身排除 if (!checksums[file]) { addFinding('warn', 'checksums-coverage', `checksums.json 未覆盖 ${file}`, 'node hooks/integrity-check.js --generate'); } } } catch (e) { addFinding('error', 'checksums-parse', 'checksums.json 解析失败: ' + e.message); } } // === 6. 验证技能索引 === function validateSkillIndex() { const indexFile = path.join(CLAUDE_ROOT, 'skills-index.json'); if (!fs.existsSync(indexFile)) { addFinding('warn', 'skill-index', 'skills-index.json 不存在 (运行 node scripts/generate-skill-index.js 生成)', 'node scripts/generate-skill-index.js'); return; } try { const index = JSON.parse(fs.readFileSync(indexFile, 'utf8')); // 检查技能数量 const skillsDir = path.join(CLAUDE_ROOT, 'skills'); const actualCount = fs.readdirSync(skillsDir) .filter(d => fs.existsSync(path.join(skillsDir, d, 'SKILL.md'))).length; if (index.skillCount !== actualCount) { addFinding('warn', 'skill-index-stale', `skills-index.json 记录 ${index.skillCount} 技能, 实际 ${actualCount} 个`, 'node scripts/generate-skill-index.js'); } } catch (e) { addFinding('error', 'skill-index-parse', 'skills-index.json 解析失败: ' + e.message); } } // === Level-1 自愈 === function applyFixes() { let fixed = 0; for (const f of findings) { if (f.fix && f.level !== 'error') { // 只执行安全的修复命令 if (f.fix.startsWith('node ')) { try { const { execSync } = require('child_process'); execSync(f.fix, { cwd: CLAUDE_ROOT, encoding: 'utf8', timeout: 10000 }); f.fixed = true; fixed++; } catch (e) { f.fixError = e.message; } } } } return fixed; } // === 主流程 === function main() { const settings = loadSettings(); if (!settings) { output(); process.exit(1); } validateHooks(settings); validateMcpServers(settings); validateRules(); validateChecksums(); validateSkillIndex(); let fixCount = 0; if (FIX_MODE) { fixCount = applyFixes(); } output(fixCount); // 有 error 级别问题时 exit 1 const errors = findings.filter(f => f.level === 'error'); if (errors.length > 0) process.exit(1); } function output(fixCount = 0) { if (JSON_MODE) { console.log(JSON.stringify({ ts: new Date().toISOString(), total: findings.length, errors: findings.filter(f => f.level === 'error').length, warnings: findings.filter(f => f.level === 'warn').length, fixed: fixCount, findings, }, null, 2)); return; } const errors = findings.filter(f => f.level === 'error'); const warnings = findings.filter(f => f.level === 'warn'); console.log('=== Bookworm Config Validator ==='); console.log(''); if (errors.length > 0) { console.log(`ERRORS (${errors.length}):`); for (const e of errors) { console.log(` [ERROR] ${e.category}: ${e.message}`); } console.log(''); } if (warnings.length > 0) { console.log(`WARNINGS (${warnings.length}):`); for (const w of warnings) { const fixTag = w.fixed ? ' [FIXED]' : (w.fix ? ` [fix: ${w.fix}]` : ''); console.log(` [WARN] ${w.category}: ${w.message}${fixTag}`); } console.log(''); } if (findings.length === 0) { console.log('All checks passed. No issues found.'); } else { console.log(`Summary: ${errors.length} errors, ${warnings.length} warnings`); if (fixCount > 0) console.log(` Auto-fixed: ${fixCount}`); } } // 导出核心函数供测试使用 if (typeof module !== 'undefined') { module.exports = { loadSettings, validateHooks, validateMcpServers, validateRules, validateChecksums, validateSkillIndex, get findings() { return findings; }, addFinding, resetFindings }; } if (require.main === module) { main(); }