mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-28 21:40:50 +00:00
391 lines
14 KiB
JavaScript
391 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||
// +----------------------------------------------------------------------
|
||
// | CRMEB 文档事实校验脚本
|
||
// +----------------------------------------------------------------------
|
||
// | 校验两类易漂移的开发资料事实:
|
||
// | 1. 代码事实数量:模块 / 驱动 / 默认安装 / MCP 内置工具 / 基础表 /
|
||
// | 开发规则 / 技能 / 子智能体数量,与各文档中的数字声明逐一比对
|
||
// | 2. 根入口文档链接:根目录各入口文件(README / AGENTS.md / CLAUDE.md /
|
||
// | llms.txt 等)中的本地相对链接目标是否存在
|
||
// |
|
||
// | 用法:node scripts/check-doc-facts.mjs [--root 仓库根目录]
|
||
// | 退出码:0=通过;1=存在 error(可接入 CI / 提交流程拦截)
|
||
// | 维护:文档中的数字声明调整措辞后,同步更新下方 DOC_FACTS 注册表
|
||
// +----------------------------------------------------------------------
|
||
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
||
import { join, resolve, dirname, relative } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||
let repoRoot = resolve(scriptDir, '..');
|
||
|
||
// --root 参数:显式指定仓库根(脚本不在 scripts/ 下运行时使用)
|
||
const rootArg = process.argv.indexOf('--root');
|
||
if (rootArg > -1 && process.argv[rootArg + 1]) {
|
||
repoRoot = resolve(process.argv[rootArg + 1]);
|
||
}
|
||
|
||
const errors = [];
|
||
const checked = { facts: 0, links: 0 };
|
||
|
||
function error(message) {
|
||
errors.push(message);
|
||
}
|
||
|
||
function readText(relPath) {
|
||
return readFileSync(join(repoRoot, relPath), 'utf8');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 一、代码事实统计(以磁盘实现为唯一基线)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 递归收集目录下指定深度的 plugin.json(用于 modules 两层/三层嵌套) */
|
||
function findPluginJson(baseDir, maxDepth) {
|
||
const found = [];
|
||
const walk = (dir, depth) => {
|
||
if (depth > maxDepth) return;
|
||
let entries;
|
||
try {
|
||
entries = readdirSync(dir, { withFileTypes: true });
|
||
} catch {
|
||
return;
|
||
}
|
||
for (const entry of entries) {
|
||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
||
const full = join(dir, entry.name);
|
||
const manifest = join(full, 'plugin.json');
|
||
if (existsSync(manifest)) found.push(manifest);
|
||
walk(full, depth + 1);
|
||
}
|
||
};
|
||
walk(baseDir, 1);
|
||
return found;
|
||
}
|
||
|
||
function readManifest(file) {
|
||
try {
|
||
return JSON.parse(readFileSync(file, 'utf8'));
|
||
} catch (e) {
|
||
error(`plugin.json 解析失败: ${relative(repoRoot, file)}(${e.message})`);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/** 模块:backend/modules 递归 plugin.json */
|
||
function statModules() {
|
||
const files = findPluginJson(join(repoRoot, 'backend/modules'), 3);
|
||
const modules = files.map((file) => {
|
||
const json = readManifest(file);
|
||
return {
|
||
key: String(json.key ?? ''),
|
||
required: json.required === true,
|
||
defaultInstall: json.default_install === true,
|
||
};
|
||
});
|
||
return {
|
||
total: modules.length,
|
||
required: modules.filter((m) => m.required).length,
|
||
defaultOnly: modules.filter((m) => m.defaultInstall && !m.required).length,
|
||
defaultAll: modules.filter((m) => m.defaultInstall || m.required).length,
|
||
list: modules,
|
||
};
|
||
}
|
||
|
||
/** 驱动:backend/driver/<类别>/<供应商>/plugin.json */
|
||
function statDrivers() {
|
||
const files = findPluginJson(join(repoRoot, 'backend/driver'), 3);
|
||
const drivers = files.map((file) => {
|
||
const json = readManifest(file);
|
||
return { key: String(json.key ?? ''), defaultInstall: json.default_install === true };
|
||
});
|
||
return {
|
||
total: drivers.length,
|
||
defaultInstall: drivers.filter((d) => d.defaultInstall).length,
|
||
list: drivers,
|
||
};
|
||
}
|
||
|
||
/** MCP 内置工具:tools/ 下以 Tool.php 结尾的类文件 */
|
||
function statMcpTools() {
|
||
const dir = join(repoRoot, 'backend/modules/service/mcp/tools');
|
||
let files = [];
|
||
try {
|
||
files = readdirSync(dir).filter((f) => f.endsWith('Tool.php'));
|
||
} catch {
|
||
error('未找到 backend/modules/service/mcp/tools/ 目录');
|
||
}
|
||
return {
|
||
total: files.length,
|
||
dev: files.filter((f) => /^Dev.+Tool\.php$/.test(f)).length,
|
||
};
|
||
}
|
||
|
||
/** 基础表:安装 SQL 中的 CREATE TABLE 语句数 */
|
||
function statTables() {
|
||
const sql = join(repoRoot, 'backend/public/install/crmeb_table.sql');
|
||
if (!existsSync(sql)) {
|
||
error('未找到 backend/public/install/crmeb_table.sql');
|
||
return 0;
|
||
}
|
||
return (readFileSync(sql, 'utf8').match(/CREATE\s+TABLE/gi) ?? []).length;
|
||
}
|
||
|
||
/** AI 开发资产:规则 / 技能 / 子智能体数量 */
|
||
function statAssets() {
|
||
const rulesDir = join(repoRoot, '.crmeb/rules');
|
||
const skillsDir = join(repoRoot, '.crmeb/skills');
|
||
const agentsDir = join(repoRoot, '.crmeb/agents');
|
||
const countMd = (dir) => {
|
||
try {
|
||
return readdirSync(dir).filter((f) => f.endsWith('.md')).length;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
};
|
||
let skills = 0;
|
||
try {
|
||
skills = readdirSync(skillsDir, { withFileTypes: true })
|
||
.filter((e) => e.isDirectory() && existsSync(join(skillsDir, e.name, 'SKILL.md')))
|
||
.length;
|
||
} catch {
|
||
/* .crmeb 缺失时归零,由声明比对报错提示 */
|
||
}
|
||
return { rules: countMd(rulesDir), skills, agents: countMd(agentsDir) };
|
||
}
|
||
|
||
const facts = {
|
||
modules: statModules(),
|
||
drivers: statDrivers(),
|
||
mcpTools: statMcpTools(),
|
||
tables: statTables(),
|
||
assets: statAssets(),
|
||
};
|
||
|
||
const FACT_LABELS = {
|
||
'modules.total': `模块总数(backend/modules/**/plugin.json)`,
|
||
'modules.required': `必装模块数(required=true)`,
|
||
'modules.defaultOnly': `默认安装模块数(default_install=true 且非必装)`,
|
||
'modules.defaultAll': `新装自动安装模块数(required 或 default_install)`,
|
||
'drivers.total': `驱动总数(backend/driver/**/plugin.json)`,
|
||
'drivers.defaultInstall': `默认安装驱动数(default_install=true)`,
|
||
'mcpTools.total': `MCP 内置工具数(tools/*Tool.php)`,
|
||
'mcpTools.dev': `MCP 开发知识工具数(Dev*Tool.php)`,
|
||
'tables.total': `基础表数(crmeb_table.sql 的 CREATE TABLE)`,
|
||
'assets.rules': `开发规则数(.crmeb/rules/*.md)`,
|
||
'assets.skills': `开发技能数(.crmeb/skills/*/SKILL.md)`,
|
||
'assets.agents': `子智能体数(.crmeb/agents/*.md)`,
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 二、文档数字声明注册表
|
||
// ---------------------------------------------------------------------------
|
||
// 每条声明 = { file, regex, checks }:
|
||
// - regex 需与文档中的声明行匹配,捕获组按顺序对应 checks 中的事实键
|
||
// - 文档措辞调整后需同步修改此处;新增数量声明时补一条注册
|
||
const DOC_FACTS = [
|
||
{
|
||
file: 'help/docs/模块与驱动.md',
|
||
regex: /\|\s*业务模块\s*\|\s*`backend\/modules\/`\s*\|\s*(\d+) 个清单/,
|
||
checks: ['modules.total'],
|
||
},
|
||
{
|
||
file: 'help/docs/模块与驱动.md',
|
||
regex: /\|\s*第三方驱动\s*\|\s*`backend\/driver\/`\s*\|\s*(\d+) 个清单/,
|
||
checks: ['drivers.total'],
|
||
},
|
||
{
|
||
file: 'help/docs/模块与驱动.md',
|
||
regex: /新装自动安装 (\d+) 个模块((\d+) 个必装 \+ (\d+) 个默认安装)/,
|
||
checks: ['modules.defaultAll', 'modules.required', 'modules.defaultOnly'],
|
||
},
|
||
{
|
||
file: 'help/docs/模块与驱动.md',
|
||
regex: /新装自动安装 (\d+) 个驱动/,
|
||
checks: ['drivers.defaultInstall'],
|
||
},
|
||
{
|
||
file: 'help/docs/项目说明.md',
|
||
regex: /当前可发现 (\d+) 个模块、(\d+) 个驱动/,
|
||
checks: ['modules.total', 'drivers.total'],
|
||
},
|
||
{
|
||
file: 'help/docs/项目说明.md',
|
||
regex: /新装默认安装 (\d+) 个模块和 (\d+) 个驱动/,
|
||
checks: ['modules.defaultAll', 'drivers.defaultInstall'],
|
||
},
|
||
{
|
||
file: 'help/docs/文档索引.md',
|
||
regex: /(\d+) 个模块、(\d+) 个驱动、默认安装清单/,
|
||
checks: ['modules.total', 'drivers.total'],
|
||
},
|
||
{
|
||
file: 'README_ZH.md',
|
||
regex: /当前内置 (\d+) 个官方模块([^)]*)与 (\d+) 个官方驱动/,
|
||
checks: ['modules.total', 'drivers.total'],
|
||
},
|
||
{
|
||
file: 'README_ZH.md',
|
||
regex: /当前内置 (\d+) 个模块与 (\d+) 个驱动/,
|
||
checks: ['modules.total', 'drivers.total'],
|
||
},
|
||
{
|
||
file: 'README.md',
|
||
regex: /(\d+) 条开发规则、(\d+) 个开发技能与 (\d+) 个专项子智能体/,
|
||
checks: ['assets.rules', 'assets.skills', 'assets.agents'],
|
||
},
|
||
{
|
||
file: '.crmeb/README.md',
|
||
regex: /(\d+) 个专项技能,详细教程/,
|
||
checks: ['assets.skills'],
|
||
},
|
||
{
|
||
file: '.crmeb/README.md',
|
||
regex: /(\d+) 个技能及按需加载的参考手册/,
|
||
checks: ['assets.skills'],
|
||
},
|
||
{
|
||
file: '.crmeb/README.md',
|
||
regex: /(\d+) 个专项角色说明/,
|
||
checks: ['assets.agents'],
|
||
},
|
||
{
|
||
file: 'help/docs/api/MCP接口.md',
|
||
regex: /当前自动发现 (\d+) 个内置工具,其中 (\d+) 个业务工具、(\d+) 个开发知识工具/,
|
||
checks: ['mcpTools.total', 'mcpTools.business', 'mcpTools.dev'],
|
||
},
|
||
];
|
||
|
||
/** 校验文档数字声明 */
|
||
function checkDocFacts() {
|
||
const factValue = (kind) => {
|
||
if (kind === 'mcpTools.business') return facts.mcpTools.total - facts.mcpTools.dev;
|
||
const [group, key] = kind.split('.');
|
||
return facts[group]?.[key];
|
||
};
|
||
const byFile = new Map();
|
||
for (const rule of DOC_FACTS) {
|
||
if (!byFile.has(rule.file)) byFile.set(rule.file, []);
|
||
byFile.get(rule.file).push(rule);
|
||
}
|
||
for (const [file, rules] of byFile) {
|
||
let content;
|
||
try {
|
||
content = readText(file);
|
||
} catch {
|
||
error(`声明校验目标不存在: ${file}`);
|
||
continue;
|
||
}
|
||
const lines = content.split('\n');
|
||
for (const rule of rules) {
|
||
let matched = false;
|
||
lines.forEach((line, idx) => {
|
||
const m = line.match(rule.regex);
|
||
if (!m) return;
|
||
matched = true;
|
||
rule.checks.forEach((kind, i) => {
|
||
checked.facts += 1;
|
||
const declared = Number(m[i + 1]);
|
||
const actual = factValue(kind);
|
||
if (Number.isNaN(declared)) {
|
||
error(`${file}:${idx + 1} 数字声明无法解析(${kind})`);
|
||
} else if (declared !== actual) {
|
||
error(
|
||
`${file}:${idx + 1} 数字漂移:声明 ${declared},代码实际 ${actual}(${FACT_LABELS[kind] ?? kind})`,
|
||
);
|
||
}
|
||
});
|
||
});
|
||
if (!matched) {
|
||
error(`声明校验规则未匹配到任何行: ${file}(${rule.regex}),文档措辞可能已变化,请同步更新 scripts/check-doc-facts.mjs`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 三、根入口文档链接校验
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const ROOT_ENTRIES = [
|
||
'README.md',
|
||
'README_ZH.md',
|
||
'AGENTS.md',
|
||
'CLAUDE.md',
|
||
'llms.txt',
|
||
'CONTRIBUTING.md',
|
||
'.crmeb/README.md',
|
||
];
|
||
|
||
/** 剥离围栏代码块,避免校验示例中的链接 */
|
||
function stripFencedBlocks(text) {
|
||
return text.replace(/^```[^\n]*\n[\s\S]*?^```/gm, (block) => block.replace(/[^\n]/g, ''));
|
||
}
|
||
|
||
function collectLinks(text, allowAngle) {
|
||
const links = [];
|
||
// Markdown 链接 [text](target),排除图片 
|
||
const mdLink = /(?<!\!)\[[^\]]*\]\(([^)\s]+)\)/g;
|
||
// llms.txt 风格内联链接 <target>(其他入口文件含 HTML 标签,不适用)
|
||
const angleLink = allowAngle ? /<(?!https?:)([^<>\s]+)>/g : null;
|
||
for (const m of text.matchAll(mdLink)) links.push(m[1]);
|
||
if (angleLink) for (const m of text.matchAll(angleLink)) links.push(m[1]);
|
||
return links;
|
||
}
|
||
|
||
function checkRootLinks() {
|
||
for (const entry of ROOT_ENTRIES) {
|
||
const fullPath = join(repoRoot, entry);
|
||
if (!existsSync(fullPath)) {
|
||
error(`根入口文件缺失: ${entry}`);
|
||
continue;
|
||
}
|
||
const text = stripFencedBlocks(readText(entry));
|
||
const baseDir = dirname(fullPath);
|
||
const allowAngle = entry === 'llms.txt';
|
||
for (const raw of collectLinks(text, allowAngle)) {
|
||
let target = raw;
|
||
// 排除外部链接、邮件与页内锚点
|
||
if (/^(https?:|mailto:|#)/i.test(target)) continue;
|
||
// 去掉页内锚点与查询串
|
||
target = target.split('#')[0].split('?')[0];
|
||
if (target === '') continue;
|
||
try {
|
||
target = decodeURIComponent(target);
|
||
} catch {
|
||
/* 保留原样 */
|
||
}
|
||
checked.links += 1;
|
||
const resolved = resolve(baseDir, target);
|
||
if (!existsSync(resolved)) {
|
||
error(`${entry}: 本地链接目标不存在 ${raw} → ${relative(repoRoot, resolved)}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 四、执行与输出
|
||
// ---------------------------------------------------------------------------
|
||
|
||
checkDocFacts();
|
||
checkRootLinks();
|
||
|
||
if (errors.length > 0) {
|
||
console.error(`文档事实校验未通过(${errors.length} 个 error):\n`);
|
||
for (const e of errors) console.error(` ${e}`);
|
||
console.error(
|
||
`\n基线统计:模块 ${facts.modules.total}(必装 ${facts.modules.required} + 默认 ${facts.modules.defaultOnly})、` +
|
||
`驱动 ${facts.drivers.total}(默认安装 ${facts.drivers.defaultInstall})、` +
|
||
`MCP 工具 ${facts.mcpTools.total}(开发知识 ${facts.mcpTools.dev})、` +
|
||
`基础表 ${facts.tables}、规则 ${facts.assets.rules} / 技能 ${facts.assets.skills} / 子智能体 ${facts.assets.agents}`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log(
|
||
`文档事实校验通过:数字声明 ${checked.facts} 处、根入口链接 ${checked.links} 条均与代码一致。` +
|
||
`基线:模块 ${facts.modules.total}、驱动 ${facts.drivers.total}、MCP 工具 ${facts.mcpTools.total}、基础表 ${facts.tables}、` +
|
||
`规则 ${facts.assets.rules} / 技能 ${facts.assets.skills} / 子智能体 ${facts.assets.agents}。`,
|
||
);
|