bookworm-smart-assistant/scripts/generate-stats.js
Bookworm Admin 34f304881f fix: strip session-continuity-mcp hooks from Portable template
export.mjs now removes hooks referencing npm packages not included
in the Portable distribution (session-continuity-mcp).
Eliminates MODULE_NOT_FOUND errors on Portable installations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 22:15:39 +08:00

352 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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:\s*true/.test(content)) composable++; // COMPOSABLE_REGEX_FIX_v1
}
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();
}