Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions plugins/openapi-contract-gate/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist/
node_modules/
12 changes: 12 additions & 0 deletions plugins/openapi-contract-gate/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# 更新日志

## 0.1.0

- 提供本地 OpenAPI/Swagger 契约比较门禁。
- 增加有界的内联 MCP 比较与一次性界面授权文件比较,返回确定性门禁结果和分页证据。
- 将 MCP 结果流式写入固定响应预算,并稳定已授权文件的全部错误边界。
- 明确界面预览与 MCP 消费的授权生命周期,并用 ctime、同句柄前后复验和 SHA-256 拒绝读取期间的文件变更。
- 严格限定 OpenAPI 3.x / Swagger 2.0 根节点,并为大型契约的节点审计、差异数量和界面分页设置上限。
- 根目录宿主清单直接指向可加载的源码 UI/preload 入口,同时仅将生成的 `dist/plugin.json` 用于发布。
- 对未压缩 `dist` 执行递归 14.5 MB 大小门禁并报告精确体积。
- 将人工界面、文件对话框、错误提示和 Markdown 报告统一为简体中文。
22 changes: 22 additions & 0 deletions plugins/openapi-contract-gate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# OpenAPI 契约门禁

一个适用于 OpenAPI 3、Swagger 2 JSON 及保守 YAML 的离线契约台账。它会比较接口端点、请求方法、参数、请求体、响应、安全要求、Schema 必填字段、类型和枚举,并为每项结果提供 JSON Pointer 证据。

YAML 只在 preload 边界解析。普通映射、序列、带引号值和块标量可以使用;锚点、别名、显式标签、重复键及远程 `$ref` 会被拒绝而不会继续解析。单个文件最大 10 MiB,嵌套深度不超过 60,审计节点不超过 40,000 个。

已验证 Node 契约测试、打包依赖检查、源码与产物一致性,以及 Chromium 渲染。Windows、macOS、Linux 的真实 ZTools 宿主加载和文件对话框行为仍未验证。

根目录 `plugin.json` 直接指向 `src/main/index.html`、`src/preload/index.cjs` 和 `logo.svg`,因此 ZTools 开发模式不依赖 `development` 覆盖即可加载界面与 preload。`npm run build` 会将 `dist/plugin.json` 重写为可独立发布的入口。`verify-dist` 递归统计 `dist` 内所有未压缩文件,打印精确字节数,并执行 14.5 MB(14,500,000 字节)安全门禁。

## Agent / MCP

ZTools 2.4+ 可把同一套保守解析器、比较器与 Markdown 报告器提供给 Agent。清单短名 `compare_inline`、`compare_approved_files` 会由宿主暴露为 `openapi_contract_gate_compare_inline`、`openapi_contract_gate_compare_approved_files`。旧宿主没有 `registerTool` 时会安全降级为原有界面。

- `compare_inline` 接受两份内联 JSON/YAML;由于 ZTools MCP 请求体上限为 1 MiB,每份 UTF-8 最多 320 KiB、合计最多 640 KiB。
- `compare_approved_files` 只消费人类在插件界面一次性选择的两份文件授权,不接受路径或授权令牌;文件仍可各到 10 MiB,但只返回最多 200 条分页发现项。界面预览读取不会消费这次授权,随后第一次 MCP 比较会消费它。

文件授权最长保留 5 分钟,仅对选择时打开的文件句柄生效。每次读取都会在同一句柄上复验设备号、inode、大小、mtime、ctime 与 SHA-256 摘要。取消选择、替换选择、授权过期、插件退出、读取或比较失败都会关闭句柄并清除授权;界面只能看到文件名与已授权的契约内容,不会获得路径、句柄或令牌。

两个工具都返回全量 `counts` 与由 `breaking` 数量确定的 `gatePassed`,Agent 不能改写或“解释通过”这个确定性门禁。MCP 比较使用流式收集器,只保留请求页而不构造全量发现项数组;发现项字段、Markdown 与最终 JSON 都有独立预算,响应最大 512 KiB,发生字段或响应裁剪时会设置 `responseTruncated`。处理器会自行拒绝未知字段、Symbol 字段、访问器、污染原型、格式、字节和分页越界;YAML 锚点、别名、显式标签、重复键以及远程 `$ref` 仍被拒绝。已授权文件失效或比较失败时会清理授权并返回稳定错误,不会透传路径或原始内容。

人工界面、文件对话框标题、错误提示与导出的 Markdown 报告均使用简体中文;MCP 工具名、错误码、字段名、`level`、`kind` 和协议值保持稳定。
1 change: 1 addition & 0 deletions plugins/openapi-contract-gate/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 30 additions & 0 deletions plugins/openapi-contract-gate/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions plugins/openapi-contract-gate/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "openapi-contract-gate",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"test": "node --test",
"build": "npm test && node scripts/build.mjs && node scripts/verify-dist.mjs",
"verify-dist": "node scripts/verify-dist.mjs"
},
"engines": {
"node": ">=16"
},
"dependencies": {
"yaml": "2.8.1"
}
}
95 changes: 95 additions & 0 deletions plugins/openapi-contract-gate/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
{
"name": "openapi-contract-gate",
"title": "OpenAPI 契约门禁",
"version": "0.1.0",
"description": "离线 OpenAPI 兼容性台账与破坏性变更门禁。",
"author": "harris",
"platform": [
"darwin",
"win32",
"linux"
],
"categories": [
"development"
],
"main": "src/main/index.html",
"preload": "src/preload/index.cjs",
"logo": "logo.svg",
"features": [
{
"code": "compare-openapi",
"icon": "logo.svg",
"explain": "比较一到两个 OpenAPI 契约",
"cmds": [
"OpenAPI 对比",
"API 契约门禁"
]
}
],
"tools": {
"compare_inline": {
"title": "比较内联 OpenAPI 契约",
"description": "离线比较两份合计不超过 640 KiB 的 JSON/YAML 契约,返回确定性门禁结果与分页证据。",
"inputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"before": {
"type": "string",
"maxLength": 327680
},
"after": {
"type": "string",
"maxLength": 327680
},
"format": {
"type": "string",
"enum": [
"auto",
"json",
"yaml"
]
},
"includeMarkdown": {
"type": "boolean"
},
"offset": {
"type": "integer",
"minimum": 0
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 200
}
},
"required": [
"before",
"after"
]
}
},
"compare_approved_files": {
"title": "比较已授权 OpenAPI 文件",
"description": "消费用户在插件界面一次性选择的两份契约,不接受文件路径或授权令牌。",
"inputSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"includeMarkdown": {
"type": "boolean"
},
"offset": {
"type": "integer",
"minimum": 0
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 200
}
}
}
}
}
}
1 change: 1 addition & 0 deletions plugins/openapi-contract-gate/scripts/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import{cp,mkdir,readFile,rm,writeFile}from'node:fs/promises';import path from'node:path';import{fileURLToPath}from'node:url';const root=path.dirname(path.dirname(fileURLToPath(import.meta.url))),d=path.join(root,'dist');await rm(d,{recursive:true,force:true});await mkdir(d,{recursive:true});await cp(path.join(root,'src'),d,{recursive:true});await cp(path.join(root,'logo.svg'),path.join(d,'logo.svg'));await mkdir(path.join(d,'preload','node_modules'),{recursive:true});await cp(path.join(root,'node_modules','yaml'),path.join(d,'preload','node_modules','yaml'),{recursive:true});const m=JSON.parse(await readFile(path.join(root,'plugin.json')));delete m.development;m.main='main/index.html';m.preload='preload/index.cjs';m.logo='logo.svg';await writeFile(path.join(d,'plugin.json'),JSON.stringify(m,null,2));
39 changes: 39 additions & 0 deletions plugins/openapi-contract-gate/scripts/dist-size.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { lstat, readdir } from 'node:fs/promises';
import path from 'node:path';

export const DIST_SIZE_LIMIT_BYTES = 14_500_000;

export function assertWithinDistSizeLimit(bytes, limit = DIST_SIZE_LIMIT_BYTES) {
if (!Number.isSafeInteger(bytes) || bytes < 0) throw new TypeError('dist byte count must be a non-negative safe integer');
if (!Number.isSafeInteger(limit) || limit < 0) throw new TypeError('dist size limit must be a non-negative safe integer');
if (bytes > limit) throw new Error(`dist is ${bytes} bytes and exceeds the 14.5 MB safety limit (${limit} bytes)`);
return bytes;
}

export async function directoryBytes(directory, options = {}) {
const {
baseDirectory = directory,
readEntries = readdir,
inspectEntry = lstat
} = options;
let total = 0;

for (const entry of await readEntries(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
const metadata = await inspectEntry(entryPath);
const relative = path.relative(baseDirectory, entryPath) || entry.name;

if (metadata.isSymbolicLink()) throw new Error(`Unsupported dist symbolic link: ${relative}`);
if (metadata.isDirectory()) {
total += await directoryBytes(entryPath, { baseDirectory, readEntries, inspectEntry });
} else if (metadata.isFile()) {
if (!Number.isSafeInteger(metadata.size) || metadata.size < 0) throw new Error(`Invalid dist file size: ${relative}`);
total += metadata.size;
if (!Number.isSafeInteger(total)) throw new Error('dist byte count exceeds the safe integer range');
} else {
throw new Error(`Unsupported dist special file: ${relative}`);
}
}

return total;
}
30 changes: 30 additions & 0 deletions plugins/openapi-contract-gate/scripts/verify-dist.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { access, readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { assertWithinDistSizeLimit, directoryBytes } from './dist-size.mjs';

const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const dist = path.join(root, 'dist');

for (const file of ['plugin.json', 'main/index.html', 'preload/index.cjs', 'core/contract.js', 'logo.svg', 'preload/node_modules/yaml/package.json']) {
await access(path.join(dist, file));
}

const manifest = JSON.parse(await readFile(path.join(dist, 'plugin.json')));
if (manifest.development) throw new Error('development leaked');
if (manifest.main !== 'main/index.html' || manifest.preload !== 'preload/index.cjs' || manifest.logo !== 'logo.svg') {
throw new Error('dist manifest does not use self-contained release entries');
}

const declared = Object.keys(manifest.tools || {}).sort();
if (JSON.stringify(declared) !== JSON.stringify(['compare_approved_files', 'compare_inline'])) {
throw new Error('dist MCP tool declarations are incomplete');
}
const preload = await readFile(path.join(dist, manifest.preload), 'utf8');
for (const name of declared) {
if (!preload.includes(`'${name}'`)) throw new Error(`dist preload does not register ${name}`);
}

const bytes = await directoryBytes(dist);
assertWithinDistSizeLimit(bytes);
console.log(`openapi-contract-gate dist verified: ${bytes} bytes (14.5 MB safety limit)`);
Loading
Loading