- VERSION file as authoritative version source - export.mjs reads VERSION with package.json fallback - bw-ota.ps1 DryRun mode for safe testing - auto-setup.ps1 bumped to v3.2.0 (Phase 8 OTA)
352 lines
12 KiB
JavaScript
352 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 系统统计自动生成器
|
||
*
|
||
* 扫描 hooks/skills/agents/settings.json,生成 stats-compiled.json
|
||
* 消除手动声明计数,作为唯一真相源
|
||
*
|
||
* 用法:
|
||
* node scripts/generate-stats.js # 生成 stats-compiled.json
|
||
* node scripts/generate-stats.js --json # 输出 JSON 到 stdout (供其他脚本消费)
|
||
* node scripts/generate-stats.js --quiet # 静默模式 (供 hook 调用)
|
||
*
|
||
* 输出文件: {root}/stats-compiled.json
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const detectRoot = () => require('./paths.config.js').PATHS.root;
|
||
|
||
const ROOT = detectRoot();
|
||
|
||
// === 扫描钩子 ===
|
||
function scanHooks() {
|
||
const hooksDir = path.join(ROOT, 'hooks');
|
||
const allFiles = fs.readdirSync(hooksDir).filter(f => f.endsWith('.js'));
|
||
|
||
// 读取 settings.json 中已注册的钩子脚本
|
||
let registeredScripts = new Set();
|
||
try {
|
||
const settings = JSON.parse(fs.readFileSync(path.join(ROOT, 'settings.json'), 'utf8'));
|
||
const hooks = settings.hooks || {};
|
||
for (const [, entries] of Object.entries(hooks)) {
|
||
for (const entry of entries) {
|
||
for (const hook of (entry.hooks || [])) {
|
||
if (hook.command) {
|
||
// 修复: 从命令中提取 .js 文件名 (处理重定向/|| true 后缀)
|
||
const jsMatch = hook.command.match(/[\w.\/\\-]+\.js/);
|
||
if (jsMatch) {
|
||
registeredScripts.add(path.basename(jsMatch[0]));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch { /* settings.json 不存在时跳过 */ }
|
||
|
||
// 分类: 按 hook 类型 (从注释头或文件名推断)
|
||
const PRE_PATTERNS = ['block-sensitive', 'block-dangerous', 'commit-message-lint', 'route-compliance'];
|
||
const ROUTE_PATTERNS = ['route-interceptor', 'subagent-route-injector', 'route-auditor'];
|
||
|
||
let preCount = 0, postCount = 0, routeCount = 0;
|
||
const registered = [];
|
||
const unregistered = [];
|
||
|
||
for (const file of allFiles) {
|
||
// 排除测试目录中的文件
|
||
if (file.startsWith('__')) continue;
|
||
|
||
const isRegistered = registeredScripts.has(file);
|
||
const info = { file, registered: isRegistered };
|
||
|
||
if (ROUTE_PATTERNS.some(p => file.includes(p))) {
|
||
info.type = 'route';
|
||
routeCount++;
|
||
} else if (PRE_PATTERNS.some(p => file.includes(p))) {
|
||
info.type = 'pre';
|
||
preCount++;
|
||
} else {
|
||
info.type = 'post';
|
||
postCount++;
|
||
}
|
||
|
||
if (isRegistered) {
|
||
registered.push(info);
|
||
} else {
|
||
unregistered.push(info);
|
||
}
|
||
}
|
||
|
||
return {
|
||
total: allFiles.length,
|
||
registered: registered.length,
|
||
unregistered: unregistered.length,
|
||
breakdown: { pre: preCount, post: postCount, route: routeCount },
|
||
unregisteredFiles: unregistered.map(u => u.file),
|
||
files: allFiles,
|
||
};
|
||
}
|
||
|
||
// === 扫描技能 ===
|
||
function scanSkills() {
|
||
const skillsDir = path.join(ROOT, 'skills');
|
||
if (!fs.existsSync(skillsDir)) return { total: 0, stable: 0, beta: 0, composable: 0, dirs: [] };
|
||
|
||
const dirs = fs.readdirSync(skillsDir).filter(d =>
|
||
fs.existsSync(path.join(skillsDir, d, 'SKILL.md'))
|
||
);
|
||
|
||
let stable = 0, beta = 0, composable = 0;
|
||
for (const dir of dirs) {
|
||
const content = fs.readFileSync(path.join(skillsDir, dir, 'SKILL.md'), 'utf8');
|
||
if (/maturity:\s*stable/.test(content)) stable++;
|
||
else if (/maturity:\s*beta/.test(content)) beta++;
|
||
if (/composable:/.test(content)) composable++;
|
||
}
|
||
|
||
return { total: dirs.length, stable, beta, composable, dirs };
|
||
}
|
||
|
||
// === 扫描智能体 ===
|
||
function scanAgents() {
|
||
const agentsDir = path.join(ROOT, 'agents');
|
||
if (!fs.existsSync(agentsDir)) return { total: 0, opus: 0, sonnet: 0, files: [] };
|
||
|
||
const files = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md'));
|
||
let opus = 0, sonnet = 0, haiku = 0;
|
||
|
||
for (const file of files) {
|
||
const content = fs.readFileSync(path.join(agentsDir, file), 'utf8');
|
||
if (/model:\s*opus/i.test(content)) opus++;
|
||
else if (/model:\s*sonnet/i.test(content)) sonnet++;
|
||
else if (/model:\s*haiku/i.test(content)) haiku++;
|
||
}
|
||
|
||
return { total: files.length, opus, sonnet, haiku, files };
|
||
}
|
||
|
||
// === 云托管 MCP 和插件 (非本地配置,手动维护) ===
|
||
const CLOUD_MCP = ['sentry', 'notion', 'gamma', 'canva', 'vercel', 'cloudinary', 'scholar-gateway', 'graphos'];
|
||
const PLUGIN_MCP = ['firebase'];
|
||
|
||
// === 扫描 MCP ===
|
||
function scanMCP() {
|
||
let localServers = [];
|
||
try {
|
||
// MCP 服务器配置在 ~/.claude.json (非 ~/.claude/settings.json)
|
||
const claudeJsonPath = path.join(path.dirname(ROOT), '.claude.json');
|
||
const config = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf8'));
|
||
const servers = config.mcpServers || {};
|
||
localServers = Object.keys(servers);
|
||
} catch {
|
||
// 回退: 尝试 settings.json
|
||
try {
|
||
const settings = JSON.parse(fs.readFileSync(path.join(ROOT, 'settings.json'), 'utf8'));
|
||
const servers = settings.mcpServers || {};
|
||
localServers = Object.keys(servers);
|
||
} catch { /* 无配置 */ }
|
||
}
|
||
|
||
return {
|
||
local: localServers.length,
|
||
cloud: CLOUD_MCP.length,
|
||
plugin: PLUGIN_MCP.length,
|
||
total: localServers.length + CLOUD_MCP.length + PLUGIN_MCP.length,
|
||
servers: localServers,
|
||
cloudServers: CLOUD_MCP,
|
||
pluginServers: PLUGIN_MCP,
|
||
};
|
||
}
|
||
|
||
// === 扫描脚本 ===
|
||
function scanScripts() {
|
||
const scriptsDir = path.join(ROOT, 'scripts');
|
||
if (!fs.existsSync(scriptsDir)) return { total: 0, files: [] };
|
||
const files = fs.readdirSync(scriptsDir).filter(f => f.endsWith('.js'));
|
||
return { total: files.length, files };
|
||
}
|
||
|
||
// === 读取技能索引 ===
|
||
function readSkillsIndex() {
|
||
try {
|
||
const index = JSON.parse(fs.readFileSync(path.join(ROOT, 'skills-index.json'), 'utf8'));
|
||
return {
|
||
skillCount: index.skillCount || 0,
|
||
totalKeywords: (index.skills || []).reduce((sum, s) => sum + (s.keywords || []).length, 0),
|
||
version: index.version || 'unknown',
|
||
};
|
||
} catch {
|
||
return { skillCount: 0, totalKeywords: 0, version: 'unknown' };
|
||
}
|
||
}
|
||
|
||
// === 主流程 ===
|
||
|
||
// === PHASE1_T1_3_MCP_OBSERVABILITY_FIELDS_2026_04_24 ===
|
||
// Phase 1 · T1.3: 读取 MCP 使用率 + 每日健康快照
|
||
function scanMcpObservability() {
|
||
const obs = { mcpUtilization: null, mcpHealth: null };
|
||
const usageFile = path.join(ROOT, 'mcp-usage-week.json');
|
||
const logsDir = path.join(ROOT, 'logs');
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const healthFile = path.join(logsDir, 'mcp-health-' + today + '.json');
|
||
|
||
try {
|
||
if (fs.existsSync(usageFile)) {
|
||
const u = JSON.parse(fs.readFileSync(usageFile, 'utf8'));
|
||
obs.mcpUtilization = {
|
||
schema_version: u.schema_version || 1,
|
||
generated: u.generated,
|
||
windowDays: u.windowDays,
|
||
totalEvents: u.totalEvents,
|
||
activeCount: Object.values(u.mcpStats || {}).filter(s => s.totalCalls > 0).length,
|
||
pruneCandidateCount: (u.pruneCandidates || []).length,
|
||
pruneCandidates: (u.pruneCandidates || []).map(p => p.server),
|
||
criticalCount: (u.criticalSet || []).length
|
||
};
|
||
}
|
||
} catch {}
|
||
|
||
try {
|
||
if (fs.existsSync(healthFile)) {
|
||
const h = JSON.parse(fs.readFileSync(healthFile, 'utf8'));
|
||
obs.mcpHealth = {
|
||
schema_version: h.schema_version || 1,
|
||
date: h.date,
|
||
probedAt: h.probedAt,
|
||
probeKind: h.probeKind,
|
||
totalMcps: h.totalMcps,
|
||
reachable: h.reachable,
|
||
unreachable: h.unreachable,
|
||
unreachableList: h.unreachableList
|
||
};
|
||
}
|
||
} catch {}
|
||
|
||
return obs;
|
||
}
|
||
// === END PHASE1_T1_3_MCP_OBSERVABILITY_FIELDS_2026_04_24 ===
|
||
|
||
function generateStats() {
|
||
const hooks = scanHooks();
|
||
const skills = scanSkills();
|
||
const agents = scanAgents();
|
||
const mcp = scanMCP();
|
||
const scripts = scanScripts();
|
||
const skillsIndex = readSkillsIndex();
|
||
const mcpObs = scanMcpObservability(); // T1.3
|
||
|
||
const stats = {
|
||
generated: new Date().toISOString(),
|
||
version: (function() {
|
||
try {
|
||
var claudeMd = require('fs').readFileSync(require('path').join(ROOT, 'CLAUDE.md'), 'utf8');
|
||
var m = claudeMd.match(/Smart Assistant[^v]*(v[\d.]+)/);
|
||
return m ? m[1] : 'v6.5';
|
||
} catch { return 'v6.5'; }
|
||
})(),
|
||
|
||
// 核心计数 (唯一真相源)
|
||
summary: {
|
||
skills: skills.total,
|
||
skillsStable: skills.stable,
|
||
skillsBeta: skills.beta,
|
||
skillsComposable: skills.composable,
|
||
agents: agents.total,
|
||
agentsOpus: agents.opus,
|
||
agentsSonnet: agents.sonnet,
|
||
agentsHaiku: agents.haiku,
|
||
hooks: hooks.total,
|
||
hooksRegistered: hooks.registered,
|
||
hooksUnregistered: hooks.unregistered,
|
||
hooksPre: hooks.breakdown.pre,
|
||
hooksPost: hooks.breakdown.post,
|
||
hooksRoute: hooks.breakdown.route,
|
||
mcp: mcp.local,
|
||
mcpCloud: mcp.cloud,
|
||
mcpPlugin: mcp.plugin,
|
||
mcpTotal: mcp.total,
|
||
scripts: scripts.total,
|
||
indexKeywords: skillsIndex.totalKeywords,
|
||
},
|
||
|
||
// 一致性校验
|
||
consistency: {
|
||
skillsMatch: skills.total === skillsIndex.skillCount,
|
||
skillsDir: skills.total,
|
||
skillsIndex: skillsIndex.skillCount,
|
||
},
|
||
|
||
// 未注册钩子清单 (供审计引用)
|
||
unregisteredHooks: hooks.unregisteredFiles,
|
||
|
||
// 设计决策: 审计时识别为有意选择,不扣分
|
||
designDecisions: {
|
||
unregisteredHooksIntentional: true,
|
||
reason: '备用钩子 (check-lint/check-typescript/integrity-check/suggest-tests) 设计为按需激活,不默认注册以避免每次文件操作额外延迟',
|
||
},
|
||
|
||
// Phase 1 · T1.3: MCP 观测字段 (PHASE1_T1_3_MCP_OBSERVABILITY_FIELDS_2026_04_24)
|
||
|
||
mcpUtilization: mcpObs.mcpUtilization,
|
||
|
||
mcpHealth: mcpObs.mcpHealth,
|
||
|
||
|
||
// 详细列表
|
||
details: {
|
||
hooks: hooks.files,
|
||
agents: agents.files,
|
||
mcp: mcp.servers,
|
||
mcpCloud: mcp.cloudServers,
|
||
mcpPlugin: mcp.pluginServers,
|
||
},
|
||
};
|
||
|
||
return stats;
|
||
}
|
||
|
||
function main() {
|
||
const args = process.argv.slice(2);
|
||
const jsonMode = args.includes('--json');
|
||
const quietMode = args.includes('--quiet');
|
||
|
||
const stats = generateStats();
|
||
|
||
// P2-V12: 原子写入 (temp+rename) 防止半写损坏
|
||
const outputPath = path.join(ROOT, 'stats-compiled.json');
|
||
const tmpPath = outputPath + '.tmp.' + process.pid;
|
||
fs.writeFileSync(tmpPath, JSON.stringify(stats, null, 2) + '\n');
|
||
fs.renameSync(tmpPath, outputPath);
|
||
|
||
if (jsonMode) {
|
||
process.stdout.write(JSON.stringify(stats));
|
||
return;
|
||
}
|
||
|
||
if (!quietMode) {
|
||
const s = stats.summary;
|
||
console.log('stats-compiled.json generated:');
|
||
console.log(` Skills: ${s.skills} (${s.skillsStable} stable, ${s.skillsBeta} beta, ${s.skillsComposable} composable)`);
|
||
console.log(` Agents: ${s.agents} (${s.agentsOpus} opus, ${s.agentsSonnet} sonnet, ${s.agentsHaiku} haiku)`);
|
||
console.log(` Hooks: ${s.hooks} (${s.hooksPre} pre + ${s.hooksPost} post + ${s.hooksRoute} route, ${s.hooksRegistered} registered, ${s.hooksUnregistered} unregistered)`);
|
||
console.log(` MCP: ${s.mcpTotal} (${s.mcp} local + ${s.mcpCloud} cloud + ${s.mcpPlugin} plugin)`);
|
||
console.log(` Scripts: ${s.scripts}`);
|
||
console.log(` Index: ${s.indexKeywords} keywords`);
|
||
if (!stats.consistency.skillsMatch) {
|
||
console.log(` [WARN] Skills mismatch: dir=${stats.consistency.skillsDir} vs index=${stats.consistency.skillsIndex}`);
|
||
}
|
||
console.log(` Output: ${outputPath}`);
|
||
}
|
||
}
|
||
|
||
// 导出供测试和其他脚本使用
|
||
if (typeof module !== 'undefined') {
|
||
module.exports = { generateStats, scanHooks, scanSkills, scanAgents, scanMCP, scanScripts };
|
||
}
|
||
|
||
if (require.main === module) {
|
||
main();
|
||
}
|