Files
deta-agent-plugins/scripts/check-plugin-contract.mjs
T
ringo abb5835310
CI / contract (push) Has been cancelled
fix(plugin): 品牌标字段改用官方口径 logo/logoDark(PNG/SVG 均可),studio 0.3.13 / writer 0.1.4
上一版把技能字段 iconSmall/iconLarge 写进了插件清单——插件不认这两个键,卡片图标不会显示。对照官方内置插件(latex/visualize/deep-research)的真实清单,插件品牌标是 interface.logo + interface.logoDark(相对插件根,PNG 或 SVG 都接受;latex 用 512×512 PNG,visualize 用 SVG),而 iconSmall/iconLarge 属于**技能**的 SkillInterface。

本提交:assets/logo.svg(浅色,门店 favicon 同源)+ assets/logo-dark.svg(深色,填色 #e5e5e5,与 @deta/ui Logo 的 currentColor 行为一致),interface.logo/logoDark 指向二者并移除 iconSmall/iconLarge;契约校验 E5 改为校验 logo/logoDark(缺失/越界/文件不存在均报错,hint 里点明 icon* 是技能字段)。已装副本 interface 键序与官方一致。
2026-09-20 16:32:29 +08:00

197 lines
8.4 KiB
JavaScript
Raw 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
/**
* 插件仓契约校验(仓库职责章程的机器化部分)
*
* 章程:本仓是**宿主适配与分发层**,零逻辑、不承载契约。
* 能力(工具实现 / 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',
},
E5: {
level: 'error',
what: '插件品牌标(interface.logo / logoDark)缺失、路径越出插件根或文件不存在',
hint: '插件清单用 logo / logoDark 指向插件根内相对路径(如 ./assets/logo.svg,深色主题给 logoDark);'
+ '注意 iconSmall/iconLarge 是**技能**的字段,插件里写了不生效',
},
};
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}`);
}
}
}
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);