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