Files
deta-agent-plugins/scripts/check-plugin-contract.mjs
T
ringo 774e27cfc8
CI / contract (push) Has been cancelled
feat(plugin): 品牌图标用 deta.city logo(iconSmall/iconLarge + 契约守卫 E5),studio 0.3.12 / writer 0.1.3
plugin.json 的 interface 补 iconSmall/iconLarge = ./assets/logo.svg(与门户 favicon 同一份 brand 标),brandColor 由 #2F6F4E 改为 #333333 对齐品牌标与门店令牌色。契约校验新增 E5:图标必须是插件根内相对路径且文件存在(实测写坏能抓到)。
2026-09-20 16:28:15 +08:00

196 lines
8.3 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.iconSmall / iconLarge 指向缺失文件或越出插件根)',
hint: '图标必须是插件根下的相对路径(如 ./assets/logo.svg),且文件真实存在——品牌标用 deta.city 的 logo.svg',
},
};
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:插件图标引用必须落在插件根内且真实存在(品牌标 = deta.city logo)
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 ['iconSmall', 'iconLarge']) {
const val = String(itf[key] ?? '');
if (!val) {
add('E5', manifest, 1, `interface.${key} 未设置(品牌标应指向 ./assets/logo.svg)`);
continue;
}
if (/^https?:/i.test(val)) continue; // *Url 形态才是远端地址
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);