CI / contract (push) Has been cancelled
命名结构(品牌=市场,产品=插件):市场名保持 deta;插件 deta → studio(displayName DETA Studio),新增 writer(DETA Writer)。安装标识 studio@deta / writer@deta;MCP 服务名 studio_mcp / writer_mcp,可同时安装。plugins/deta → plugins/studio(git mv 保留历史),新增 plugins/writer。 连接形态统一为单一本机 stdio 管道:.mcp.json 与 cordis.patch.yml 均声明 stdio → bin/deta-mcp.sh --profile <产品>,并按官方插件写法补 cwd: "."(缺它时 Cwd=- ,相对命令按宿主进程 cwd 解析会静默起不来);启动器新增 --profile 档位(工作区 ~/.deta/<产品>/workspace、工具面缺省 full),运行时按 DETA_SERVER_ROOT → ~/.deta/runtime → 开发检出解析,入口优先打包产物;DETA_TOKEN 可选注入(缺省时云类工具返回 auth_required)。原远程端点声明(含 OAuth/401 段落)从插件移除。 文档与治理:根 README 与两个插件 README 改为单一管道口径;技能 aml-design 对齐本机全量面;新增 scripts/check-plugin-contract.mjs(E1 禁工具清单 / E2 必须给出 list_capabilities / E3 禁解释内部实现 / E4 市场条目一致性 / W1 开发检出依赖)与 .gitea/workflows/ci.yml。版本:studio 0.3.1、writer 0.1.0。
164 lines
7.0 KiB
JavaScript
164 lines
7.0 KiB
JavaScript
#!/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)。
|
||
*
|
||
* 用法:node scripts/check-plugin-contract.mjs [--json]
|
||
* 退出码:有 error 时 1,否则 0(warning 不失败)。
|
||
*/
|
||
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',
|
||
},
|
||
};
|
||
|
||
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} 不一致`);
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|