#!/usr/bin/env node /** * 插件仓契约校验(仓库职责章程的机器化部分) * * 章程:本仓是**宿主适配与分发层**,零逻辑、不承载契约。 * 能力(工具实现 / MCP 协议 / 启动形态 / 工具面裁剪 / 领域技能)唯一来源在 ant-copilot; * 本仓只保留「宿主清单 + 连接声明 + 启动器 + 宿主侧投影」。 * * 规则: * E1 技能文档不得出现「工具一览 / 工具清单」类章节标题 * —— 工具面随部署形态裁剪(engine / full),任何手写清单必然过期; * 取清单的唯一途径是运行时调 `list_capabilities`。 * E2 技能文档必须给出「取当前形态工具名」的途径(出现 `list_capabilities`) * —— 正面要求,零误报:只要技能不许自带清单、又必须教模型去哪查, * 工具面增删就与本文档解耦。(曾试过"段落内枚举 ≥5 个标识符"的启发式, * 误报率高——流程散文里正常提及工具名会被误判,已弃用。) * E3 插件 README 不得解释领域服务内部常量 * —— 那是 ant-copilot 的实现细节,文档越界即漂移源。 * W1 不得出现开发检出路径(~/Dev/ant/ant-copilot) * —— 本机引擎包落地后升级为错误(见章程 M2)。 * E6 运行时包必须自洽:runtime/dist/deta-mcp.mjs 存在,其 sha256/字节数与 runtime/manifest.json 一致, * 且 manifest.pluginVersion 等于插件清单版本 * —— 运行时随插件分发(私有 registry 匿名读 401,走 npm 会让每台机器都要 token), * 因此「升了版本忘了重跑 sync-runtime」会静默发出发错版本的运行时;这条规则把它变成红灯。 * * 用法:node scripts/check-plugin-contract.mjs [--json] * 退出码:有 error 时 1,否则 0(warning 不失败)。 */ import { createHash } from 'node:crypto'; import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; import { join, relative, basename, resolve } from 'node:path'; const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, ''); const PLUGINS = join(ROOT, 'plugins'); /** 本目录是工具自身,不参与内容校验(其注释里必然出现被禁模式) */ const SKIP_DIRS = new Set(['node_modules', '.git', 'scripts']); /** 遍历目录,返回全部文件绝对路径 */ function walk(dir) { const out = []; for (const name of readdirSync(dir)) { if (SKIP_DIRS.has(name)) continue; const p = join(dir, name); const st = statSync(p); if (st.isDirectory()) out.push(...walk(p)); else out.push(p); } return out; } const RULES = { E1: { level: 'error', what: '技能文档出现「工具一览 / 工具清单」类章节标题', hint: '删掉清单,改为「不确定有哪些工具时调 list_capabilities」——工具面按形态裁剪,手写清单必然过期', }, E2: { level: 'error', what: '技能文档没有给出取当前形态工具名的途径(缺 list_capabilities 指引)', hint: '在技能开头加一句:不确定有哪些工具时先调 list_capabilities 拿完整工具名,再按名字直接调用', }, E3: { level: 'error', what: '插件 README 解释领域服务内部常量', hint: 'ant-copilot 的实现细节不进本仓文档;本仓只写安装 / 连接 / 排障', }, E4: { level: 'error', what: '市场条目与实际插件目录不一致(悬空条目或名称不匹配)', hint: '条目 name 必须等于该目录 .codex-plugin/plugin.json 的 name,path 必须真实存在——改名/加产品时同步改市场文件', }, W1: { level: 'warning', what: '出现开发检出路径(~/Dev/ant/ant-copilot)', hint: '启动器应随包分发运行时;本机引擎包落地后本规则升级为 error', }, E5: { level: 'error', what: '插件品牌标(interface.logo / logoDark)缺失、路径越出插件根或文件不存在', hint: '插件清单用 logo / logoDark 指向插件根内相对路径(如 ./assets/logo.svg,深色主题给 logoDark);' + '注意 iconSmall/iconLarge 是**技能**的字段,插件里写了不生效', }, E6: { level: 'error', what: '运行时包不自洽(缺 runtime/、sha256 或字节数与 manifest 不符、manifest.pluginVersion 与插件版本不符)', hint: '运行时随插件分发:改动 ant-copilot 后跑 node scripts/sync-runtime.mjs,再升插件版本重装', }, }; const HEADING_BAN = /工具一览|工具清单|工具列表|工具汇总|可用工具|能力一览|能力清单/; /** * 只认「赋值/缺省」形态(`VAR=` 开头)——那才是"解释内部语义"的信号; * 裸写常量名的行常常是章程里在说"不要解释它",或启动器在转发自己的用户级旋钮, * 一律放行(宁可漏报,不要把自己的章程判成违规)。 */ const INTERNAL_CONST = /\b(?:DETA_TOOL_SURFACE|DETA_MCP_PORT|DETA_GSA_URL)\s*=|dist\/src\/headless|dist\/deta-mcp/; const DEV_PATH = /(?:\$HOME|~|\/Users\/[^/\s]+)\/Dev\/ant\/ant-copilot/; const findings = []; const add = (rule, file, line, evidence) => findings.push({ rule, level: RULES[rule].level, file: relative(ROOT, file), line, evidence: evidence.trim() }); const files = walk(ROOT); for (const file of files) { const isSkill = file.includes(`${join('skills', '')}`) && basename(file) === 'SKILL.md'; const isReadme = basename(file).toLowerCase().startsWith('readme'); const isText = /\.(md|ya?ml|json|mjs|sh|ts)$/.test(file); let text; try { text = readFileSync(file, 'utf8'); } catch { continue; } const lines = text.split('\n'); lines.forEach((line, i) => { const n = i + 1; // E1:技能文档的章节标题 if (isSkill && /^#{2,6}\s/.test(line) && HEADING_BAN.test(line)) add('E1', file, n, line); // E3:README 里的领域服务内部常量 if (isReadme && INTERNAL_CONST.test(line)) add('E3', file, n, line); // W1:开发检出路径 if (isText && DEV_PATH.test(line)) add('W1', file, n, line); }); // E2:技能必须教模型去哪取工具名(正面要求,零误报) if (isSkill && !text.includes('list_capabilities')) { add('E2', file, 1, '全文未出现 list_capabilities'); } } // E4:市场条目 ↔ 插件目录一致性(改名 / 加产品时防悬空条目) for (const rel of ['.agents/plugins/marketplace.json', '.agents/plugins/api_marketplace.json']) { const abs = join(ROOT, rel); if (!existsSync(abs)) continue; let market; try { market = JSON.parse(readFileSync(abs, 'utf8')); } catch (e) { add('E4', abs, 1, `市场文件不是合法 JSON:${e.message}`); continue; } for (const entry of market.plugins ?? []) { const relPath = String(entry?.source?.path ?? ''); const manifest = join(resolve(ROOT, relPath), '.codex-plugin', 'plugin.json'); if (!relPath || !existsSync(manifest)) { add('E4', abs, 1, `条目 name=${entry?.name ?? '?'} 指向的插件目录不存在或缺插件清单:${relPath || '(空 path)'}`); continue; } let name = ''; try { name = String(JSON.parse(readFileSync(manifest, 'utf8')).name ?? ''); } catch { // 清单本身不合法由 validate_plugin.py 报,这里不重复 } if (name !== entry.name) { add('E4', abs, 1, `市场条目 name=${entry.name} 与插件清单 name=${name} 不一致`); } } } // E5:插件品牌标(logo / logoDark)必须落在插件根内且真实存在 for (const pluginDir of ['plugins/studio', 'plugins/writer']) { const manifest = join(ROOT, pluginDir, '.codex-plugin', 'plugin.json'); if (!existsSync(manifest)) continue; let itf; try { itf = JSON.parse(readFileSync(manifest, 'utf8')).interface ?? {}; } catch { continue; // 清单不合法由 validate_plugin.py 报 } for (const key of ['logo', 'logoDark']) { const val = String(itf[key] ?? ''); if (!val) { add('E5', manifest, 1, `interface.${key} 未设置(品牌标应指向 ./assets/logo.svg / ./assets/logo-dark.svg)`); continue; } if (/^https?:/i.test(val)) continue; // 远端地址形态 if (val.includes('..') || !val.startsWith('./')) { add('E5', manifest, 1, `${key} 必须是插件根内相对路径(收到 ${val})`); continue; } if (!existsSync(join(ROOT, pluginDir, val.replace(/^\.\//, '')))) { add('E5', manifest, 1, `${key} 指向的文件不存在:${val}`); } } } // E6:运行时包自洽(插件自带打包产物 → 装插件即可用;防「升版本忘重跑 sync-runtime」多发错版本) for (const pluginDir of ['plugins/studio', 'plugins/writer']) { const pluginRoot = join(ROOT, pluginDir); const entry = join(pluginRoot, 'runtime', 'dist', 'deta-mcp.mjs'); const mPath = join(pluginRoot, 'runtime', 'manifest.json'); if (!existsSync(entry) || !existsSync(mPath)) { add('E6', pluginDir, 1, '缺少运行时包(runtime/dist/deta-mcp.mjs + runtime/manifest.json)——跑 node scripts/sync-runtime.mjs'); continue; } let m; try { m = JSON.parse(readFileSync(mPath, 'utf8')); } catch (e) { add('E6', mPath, 1, `manifest.json 解析失败:${e.message}`); continue; } const buf = readFileSync(entry); const sha = createHash('sha256').update(buf).digest('hex'); if (sha !== m.sha256) { add('E6', mPath, 1, `运行时 sha256 与 manifest 不一致(文件 ${sha.slice(0, 12)}… ≠ manifest ${String(m.sha256).slice(0, 12)}…)——重跑 sync-runtime`); } if (buf.length !== m.bytes) { add('E6', mPath, 1, `运行时字节数与 manifest 不一致(${buf.length} ≠ ${m.bytes})`); } try { const pv = JSON.parse(readFileSync(join(pluginRoot, '.codex-plugin', 'plugin.json'), 'utf8')).version; if (m.pluginVersion !== pv) { add('E6', mPath, 1, `manifest.pluginVersion=${m.pluginVersion} 与插件版本 ${pv} 不一致——升版本后必须重跑 sync-runtime`); } } catch { // 清单本身不合法由官方校验器报 } } if (process.argv.includes('--json')) { console.log(JSON.stringify({ findings, rules: RULES }, null, 2)); } else { for (const f of findings) { const mark = f.level === 'error' ? '✗' : '!'; console.log(`${mark} [${f.rule}] ${f.file}:${f.line}`); console.log(` ${f.evidence.slice(0, 140)}`); if (f.level === 'error') console.log(` → ${RULES[f.rule].hint}`); } const errs = findings.filter((f) => f.level === 'error').length; const warns = findings.length - errs; console.log(`\n契约校验:${errs} 个错误 / ${warns} 个警告`); if (!findings.length) console.log('✓ 全部通过'); } process.exit(findings.some((f) => f.level === 'error') ? 1 : 0);