diff --git a/plugins/openapi-contract-gate/.gitignore b/plugins/openapi-contract-gate/.gitignore
new file mode 100644
index 00000000..1eae0cf6
--- /dev/null
+++ b/plugins/openapi-contract-gate/.gitignore
@@ -0,0 +1,2 @@
+dist/
+node_modules/
diff --git a/plugins/openapi-contract-gate/CHANGELOG.md b/plugins/openapi-contract-gate/CHANGELOG.md
new file mode 100644
index 00000000..5090cc74
--- /dev/null
+++ b/plugins/openapi-contract-gate/CHANGELOG.md
@@ -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 报告统一为简体中文。
diff --git a/plugins/openapi-contract-gate/README.md b/plugins/openapi-contract-gate/README.md
new file mode 100644
index 00000000..fd86ea56
--- /dev/null
+++ b/plugins/openapi-contract-gate/README.md
@@ -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` 和协议值保持稳定。
diff --git a/plugins/openapi-contract-gate/logo.svg b/plugins/openapi-contract-gate/logo.svg
new file mode 100644
index 00000000..ce614357
--- /dev/null
+++ b/plugins/openapi-contract-gate/logo.svg
@@ -0,0 +1 @@
+
diff --git a/plugins/openapi-contract-gate/package-lock.json b/plugins/openapi-contract-gate/package-lock.json
new file mode 100644
index 00000000..faebec1e
--- /dev/null
+++ b/plugins/openapi-contract-gate/package-lock.json
@@ -0,0 +1,30 @@
+{
+ "name": "openapi-contract-gate",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openapi-contract-gate",
+ "version": "0.1.0",
+ "dependencies": {
+ "yaml": "2.8.1"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/yaml": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.1.tgz",
+ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ }
+ }
+ }
+}
diff --git a/plugins/openapi-contract-gate/package.json b/plugins/openapi-contract-gate/package.json
new file mode 100644
index 00000000..4436e2a2
--- /dev/null
+++ b/plugins/openapi-contract-gate/package.json
@@ -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"
+ }
+}
diff --git a/plugins/openapi-contract-gate/plugin.json b/plugins/openapi-contract-gate/plugin.json
new file mode 100644
index 00000000..5e6768de
--- /dev/null
+++ b/plugins/openapi-contract-gate/plugin.json
@@ -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
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/plugins/openapi-contract-gate/scripts/build.mjs b/plugins/openapi-contract-gate/scripts/build.mjs
new file mode 100644
index 00000000..560bb4db
--- /dev/null
+++ b/plugins/openapi-contract-gate/scripts/build.mjs
@@ -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));
diff --git a/plugins/openapi-contract-gate/scripts/dist-size.mjs b/plugins/openapi-contract-gate/scripts/dist-size.mjs
new file mode 100644
index 00000000..30964eda
--- /dev/null
+++ b/plugins/openapi-contract-gate/scripts/dist-size.mjs
@@ -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;
+}
diff --git a/plugins/openapi-contract-gate/scripts/verify-dist.mjs b/plugins/openapi-contract-gate/scripts/verify-dist.mjs
new file mode 100644
index 00000000..946ee64e
--- /dev/null
+++ b/plugins/openapi-contract-gate/scripts/verify-dist.mjs
@@ -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)`);
diff --git a/plugins/openapi-contract-gate/src/core/contract.js b/plugins/openapi-contract-gate/src/core/contract.js
new file mode 100644
index 00000000..a91d45bb
--- /dev/null
+++ b/plugins/openapi-contract-gate/src/core/contract.js
@@ -0,0 +1,362 @@
+const MAX_BYTES = 10 * 1024 * 1024;
+const MAX_DEPTH = 60;
+const MAX_NODES = 40000;
+const MAX_FINDINGS = 10000;
+const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
+
+export function pathContract(platform, file) {
+ const name = String(file || '').split(platform === 'win32' ? /[\\/]/ : /\//).pop();
+ return { platform, name, accepted: /\.(json|ya?ml)$/i.test(name) };
+}
+
+function utf8Length(text) {
+ return typeof TextEncoder !== 'undefined' ? new TextEncoder().encode(text).length : Buffer.byteLength(text, 'utf8');
+}
+
+function auditDocument(root) {
+ const stack = [[root, 0]];
+ const seen = new Set();
+ let nodes = 0;
+ while (stack.length) {
+ const [value, depth] = stack.pop();
+ if (depth > MAX_DEPTH) throw Error('契约文档嵌套层级超过限制');
+ if (++nodes > MAX_NODES) throw Error('契约文档节点数量超过限制');
+ if (value === null || typeof value !== 'object' || seen.has(value)) continue;
+ seen.add(value);
+ for (const [key, next] of Object.entries(value)) {
+ if (key === '$ref' && typeof next === 'string' && !next.startsWith('#/')) throw Error('不允许远程 $ref');
+ stack.push([next, depth + 1]);
+ }
+ }
+}
+
+function isPlainObject(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ const prototype = Object.getPrototypeOf(value);
+ return prototype === Object.prototype || prototype === null;
+}
+
+function validateDocumentShape(document) {
+ const openapi3 = typeof document?.openapi === 'string' && /^3\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(document.openapi);
+ const swagger2 = document?.swagger === '2.0';
+ if (!isPlainObject(document) || (!openapi3 && !swagger2)) throw Error('根节点必须是 OpenAPI 3.x 或 Swagger 2.0 契约');
+ if (!isPlainObject(document.paths)) throw Error('paths 必须是普通对象');
+}
+
+export function parseDocument(text) {
+ const source = String(text);
+ if (utf8Length(source) > MAX_BYTES) throw Error('契约文档超过 10 MiB 限制');
+ let document;
+ try { document = JSON.parse(source); } catch { throw Error('JSON 契约格式无效'); }
+ validateDocumentShape(document);
+ auditDocument(document);
+ return document;
+}
+
+export function createFindingPageCollector(offset = 0, limit = 100) {
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw Error('差异分页参数无效');
+ const findings = [];
+ const counts = { breaking: 0, nonBreaking: 0, info: 0, total: 0 };
+ return {
+ findings,
+ counts,
+ offset,
+ limit,
+ push(item) {
+ const index = counts.total++;
+ if (item?.level === 'breaking') counts.breaking++;
+ else if (item?.level === 'non-breaking') counts.nonBreaking++;
+ else if (item?.level === 'info') counts.info++;
+ if (index >= offset && findings.length < limit) findings.push(item);
+ return counts.total;
+ }
+ };
+}
+
+function pointer(...parts) {
+ const base = typeof parts[0] === 'string' && parts[0].startsWith('/') ? parts.shift().replace(/\/$/, '') : '';
+ return `${base}/${parts.map((part) => String(part).replace(/~/g, '~0').replace(/\//g, '~1')).join('/')}`;
+}
+function resolve(value, document) {
+ let current = value;
+ const visited = new Set();
+ for (let depth = 0; current?.$ref; depth++) {
+ const ref = current.$ref;
+ if (depth >= MAX_DEPTH || visited.has(ref)) throw Error('本地 $ref 循环引用超过限制');
+ if (typeof ref !== 'string' || !ref.startsWith('#/')) throw Error('仅允许本地 $ref 值');
+ visited.add(ref);
+ let target = document;
+ for (const rawPart of ref.slice(2).split('/')) {
+ const part = decodeURIComponent(rawPart).replace(/~1/g, '/').replace(/~0/g, '~');
+ if (!target || typeof target !== 'object' || !Object.prototype.hasOwnProperty.call(target, part)) throw Error(`本地 $ref 无效:${ref}`);
+ target = target[part];
+ }
+ current = target;
+ }
+ return current === undefined ? {} : current;
+}
+function schemaForParameter(parameter) {
+ if (parameter?.schema) return parameter.schema;
+ const contentSchema = Object.values(parameter?.content || {})[0]?.schema;
+ if (contentSchema) return contentSchema;
+ if (!parameter) return undefined;
+ const { name, in: location, required, description, ...schema } = parameter;
+ return Object.keys(schema).length ? schema : undefined;
+}
+function finding(level, kind, where, reason) { return { level, kind, pointer: where, reason }; }
+function directionLabel(direction) { return direction === 'request' ? '请求' : '响应'; }
+function values(value) { return value === undefined ? null : new Set(Array.isArray(value) ? value : [value]); }
+function missing(from, within) { return [...from].filter((item) => !within.has(item)); }
+function additionalMode(schema) {
+ const value = schema.additionalProperties;
+ return value === undefined || value === true ? 'any' : value === false ? 'none' : 'schema';
+}
+function compareAdditionalProperties(oldValue, newValue, where, out, oldDoc, newDoc, direction, pairs) {
+ const oldMode = additionalMode(oldValue), newMode = additionalMode(newValue);
+ const requestBreak = oldMode === 'any' && newMode !== 'any' || oldMode === 'schema' && newMode === 'none';
+ const responseBreak = oldMode === 'none' && newMode !== 'none' || oldMode === 'schema' && newMode === 'any';
+ if (direction === 'request' && requestBreak || direction === 'response' && responseBreak) {
+ out.push(finding('breaking', 'schema.additionalProperties', pointer(where, 'additionalProperties'), `${directionLabel(direction)}的 additionalProperties 兼容范围收窄`));
+ }
+ if (oldMode === 'schema' && newMode === 'schema') {
+ compareSchema(oldValue.additionalProperties, newValue.additionalProperties, pointer(where, 'additionalProperties'), out, oldDoc, newDoc, direction, pairs);
+ }
+}
+function changed(left, right) { return JSON.stringify(left) !== JSON.stringify(right); }
+function breakingConstraint(out, where, name, direction) { out.push(finding('breaking', `schema.${name}`, pointer(where, name), `${directionLabel(direction)}约束 ${name} 的兼容性发生变化`)); }
+function compareAssertions(oldValue, newValue, where, out, oldDoc, newDoc, direction, pairs) {
+ const request = direction === 'request';
+ const tightenedMinimum = (name) => request ? newValue[name] !== undefined && (oldValue[name] === undefined || newValue[name] > oldValue[name]) : oldValue[name] !== undefined && (newValue[name] === undefined || newValue[name] < oldValue[name]);
+ const tightenedMaximum = (name) => request ? newValue[name] !== undefined && (oldValue[name] === undefined || newValue[name] < oldValue[name]) : oldValue[name] !== undefined && (newValue[name] === undefined || newValue[name] > oldValue[name]);
+ for (const name of ['minLength', 'minimum', 'exclusiveMinimum', 'minItems', 'minProperties']) if (tightenedMinimum(name)) breakingConstraint(out, where, name, direction);
+ for (const name of ['maxLength', 'maximum', 'exclusiveMaximum', 'maxItems', 'maxProperties']) if (tightenedMaximum(name)) breakingConstraint(out, where, name, direction);
+ if (request && oldValue.nullable && !newValue.nullable || !request && !oldValue.nullable && newValue.nullable) breakingConstraint(out, where, 'nullable', direction);
+ if (request && newValue.const !== undefined && changed(oldValue.const, newValue.const) || !request && oldValue.const !== undefined && changed(oldValue.const, newValue.const)) breakingConstraint(out, where, 'const', direction);
+ for (const name of ['pattern', 'format', 'multipleOf']) {
+ const oldAssertion = oldValue[name], newAssertion = newValue[name];
+ if (request && newAssertion !== undefined && changed(oldAssertion, newAssertion) || !request && oldAssertion !== undefined && changed(oldAssertion, newAssertion)) breakingConstraint(out, where, name, direction);
+ }
+ if (request && !oldValue.uniqueItems && newValue.uniqueItems || !request && oldValue.uniqueItems && !newValue.uniqueItems) breakingConstraint(out, where, 'uniqueItems', direction);
+ if (Object.prototype.hasOwnProperty.call(oldValue, 'items') || Object.prototype.hasOwnProperty.call(newValue, 'items')) {
+ if (!Object.prototype.hasOwnProperty.call(oldValue, 'items') || !Object.prototype.hasOwnProperty.call(newValue, 'items')) breakingConstraint(out, where, 'items', direction);
+ else compareSchema(oldValue.items, newValue.items, pointer(where, 'items'), out, oldDoc, newDoc, direction, pairs);
+ }
+ for (const name of ['oneOf', 'anyOf', 'allOf', 'not', 'if', 'then', 'else', 'contains', 'prefixItems']) {
+ if (changed(oldValue[name], newValue[name])) out.push(finding('breaking', 'schema.inconclusive', pointer(where, name), `${directionLabel(direction)}约束 ${name} 发生变化,无法证明兼容性`));
+ }
+ const handled = new Set(['$ref', 'type', 'enum', 'nullable', 'const', 'minLength', 'maxLength', 'pattern', 'format', 'multipleOf', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'minItems', 'maxItems', 'uniqueItems', 'items', 'minProperties', 'maxProperties', 'properties', 'required', 'additionalProperties', 'oneOf', 'anyOf', 'allOf', 'not', 'if', 'then', 'else', 'contains', 'prefixItems']);
+ const metadata = new Set(['title', 'description', 'default', 'example', 'examples', 'deprecated', 'externalDocs', '$id', '$schema']);
+ for (const name of new Set([...Object.keys(oldValue), ...Object.keys(newValue)])) {
+ if (!handled.has(name) && !metadata.has(name) && changed(oldValue[name], newValue[name])) out.push(finding('breaking', 'schema.inconclusive', pointer(where, name), `${directionLabel(direction)}约束 ${name} 发生变化,无法证明兼容性`));
+ }
+}
+
+function compareSchema(oldSchema, newSchema, where, out, oldDoc, newDoc, direction, pairs = new WeakMap()) {
+ if (oldSchema === undefined || newSchema === undefined) return;
+ const oldValue = resolve(oldSchema, oldDoc);
+ const newValue = resolve(newSchema, newDoc);
+ if (typeof oldValue === 'boolean' || typeof newValue === 'boolean') {
+ if (oldValue !== newValue) out.push(finding('breaking', 'schema.boolean', where, `${directionLabel(direction)}的布尔 Schema 兼容性发生变化`));
+ return;
+ }
+ if (typeof oldValue === 'object' && typeof newValue === 'object') {
+ let targets = pairs.get(oldValue);
+ if (!targets) { targets = new WeakSet(); pairs.set(oldValue, targets); }
+ if (targets.has(newValue)) return;
+ targets.add(newValue);
+ }
+ const oldTypes = values(oldValue.type), newTypes = values(newValue.type);
+ if ((direction === 'request' && !oldTypes && newTypes) || (direction === 'response' && oldTypes && !newTypes)) {
+ out.push(finding('breaking', 'schema.type', where, `${directionLabel(direction)}类型兼容范围收窄`));
+ } else if (oldTypes && newTypes) {
+ const invalid = direction === 'request' ? missing(oldTypes, newTypes) : missing(newTypes, oldTypes);
+ if (invalid.length) out.push(finding('breaking', 'schema.type', where, `${directionLabel(direction)}类型兼容性发生变化:${invalid.join('、')}`));
+ }
+ if ((direction === 'request' && !oldValue.enum && newValue.enum) || (direction === 'response' && oldValue.enum && !newValue.enum)) {
+ out.push(finding('breaking', 'schema.enum', where, `${directionLabel(direction)}枚举兼容范围收窄`));
+ } else if (oldValue.enum && newValue.enum) {
+ const invalid = direction === 'request' ? missing(new Set(oldValue.enum), new Set(newValue.enum)) : missing(new Set(newValue.enum), new Set(oldValue.enum));
+ if (invalid.length) out.push(finding('breaking', 'schema.enum', where, `${directionLabel(direction)}枚举兼容性发生变化:${invalid.join('、')}`));
+ }
+ const oldRequired = new Set(oldValue.required || []), newRequired = new Set(newValue.required || []);
+ if (direction === 'request') for (const name of missing(newRequired, oldRequired)) out.push(finding('breaking', 'schema.required', pointer(where, 'required'), `字段 ${name} 变为必填`));
+ if (direction === 'response') for (const name of missing(oldRequired, newRequired)) out.push(finding('breaking', 'schema.required', pointer(where, 'required'), `响应字段 ${name} 不再保证必填`));
+ const oldProperties = oldValue.properties || {}, newProperties = newValue.properties || {};
+ for (const [name, oldProperty] of Object.entries(oldProperties)) {
+ if (!Object.prototype.hasOwnProperty.call(newProperties, name)) {
+ if (direction === 'response') out.push(finding('breaking', 'schema.property', pointer(where, 'properties', name), '响应属性已移除'));
+ if (direction === 'request') out.push(finding('breaking', 'schema.property', pointer(where, 'properties', name), '原本接受的请求属性已移除'));
+ continue;
+ }
+ compareSchema(oldProperty, newProperties[name], pointer(where, 'properties', name), out, oldDoc, newDoc, direction, pairs);
+ }
+ compareAdditionalProperties(oldValue, newValue, where, out, oldDoc, newDoc, direction, pairs);
+ compareAssertions(oldValue, newValue, where, out, oldDoc, newDoc, direction, pairs);
+ if (direction === 'request') for (const name of Object.keys(newProperties)) if (!Object.prototype.hasOwnProperty.call(oldProperties, name) && !newRequired.has(name)) out.push(finding('non-breaking', 'schema.property', pointer(where, 'properties', name), '新增可选请求属性'));
+}
+
+function parameters(operation, pathItem, document) {
+ const merged = new Map();
+ for (const parameter of [...(pathItem?.parameters || []), ...(operation.parameters || [])]) {
+ const resolved = resolve(parameter, document);
+ merged.set(`${resolved.in}:${resolved.name}`, resolved);
+ }
+ return [...merged.values()];
+}
+function effectiveSecurity(document, operation) {
+ const value = Object.prototype.hasOwnProperty.call(operation, 'security') ? operation.security : document.security;
+ if (!Array.isArray(value) || value.length === 0) return [];
+ if (value.some((requirement) => isPlainObject(requirement) && Object.keys(requirement).length === 0)) return [];
+ return value.map((requirement) => Object.fromEntries(Object.keys(requirement).sort().map((key) => [key, [...(requirement[key] || [])].sort()])))
+ .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
+}
+function sameSecurity(left, right) { return JSON.stringify(left) === JSON.stringify(right); }
+function compareSecurity(oldDoc, newDoc, oldOperation, newOperation, where, out) {
+ const oldSecurity = effectiveSecurity(oldDoc, oldOperation), newSecurity = effectiveSecurity(newDoc, newOperation);
+ if (sameSecurity(oldSecurity, newSecurity)) return;
+ if (newSecurity.length === 0) out.push(finding('info', 'security', where, '操作现在允许匿名访问'));
+ else if (oldSecurity.length === 0) out.push(finding('breaking', 'security', where, '操作现在要求身份验证'));
+ else out.push(finding('breaking', 'security', where, '实际生效的安全要求发生变化'));
+}
+function compareRequestBody(oldBody, newBody, where, out, oldDoc, newDoc) {
+ oldBody = oldBody && resolve(oldBody, oldDoc);
+ newBody = newBody && resolve(newBody, newDoc);
+ if (!oldBody && newBody?.required) { out.push(finding('breaking', 'requestBody.required', where, '新增必填请求体')); return; }
+ if (oldBody && !newBody) { out.push(finding('breaking', 'requestBody.content', where, '已移除请求体支持')); return; }
+ if (!oldBody || !newBody) return;
+ if (!oldBody.required && newBody.required) out.push(finding('breaking', 'requestBody.required', where, '请求体变为必填'));
+ for (const [type, media] of Object.entries(oldBody.content || {})) {
+ if (!newBody.content?.[type]) out.push(finding('breaking', 'requestBody.content', pointer(where, 'content', type), `已移除原本接受的内容类型 ${type}`));
+ else compareSchema(media.schema, newBody.content[type].schema, pointer(where, 'content', type, 'schema'), out, oldDoc, newDoc, 'request');
+ }
+}
+function compareResponse(oldResponse, newResponse, where, out, oldDoc, newDoc) {
+ oldResponse = resolve(oldResponse, oldDoc);
+ newResponse = resolve(newResponse, newDoc);
+ const oldContent = oldResponse.content;
+ const newContent = newResponse.content;
+ if (!oldContent && !newContent) {
+ compareSchema(oldResponse.schema, newResponse.schema, pointer(where, 'schema'), out, oldDoc, newDoc, 'response');
+ return;
+ }
+ for (const [type, media] of Object.entries(oldContent || {})) {
+ if (!newContent?.[type]) out.push(finding('breaking', 'response.content', pointer(where, 'content', type), `已移除响应内容类型 ${type}`));
+ else compareSchema(media.schema, newContent[type].schema, pointer(where, 'content', type, 'schema'), out, oldDoc, newDoc, 'response');
+ }
+}
+
+function effectiveSwaggerMediaTypes(document, operation, field) {
+ if (document?.swagger !== '2.0') return null;
+ const source = Object.prototype.hasOwnProperty.call(operation, field) ? operation[field] : document[field];
+ return new Set(Array.isArray(source) ? source.filter((item) => typeof item === 'string') : []);
+}
+function compareSwaggerMediaTypeSet(oldDoc, newDoc, oldOperation, newOperation, base, field, kind, label, out) {
+ const oldTypes = effectiveSwaggerMediaTypes(oldDoc, oldOperation, field);
+ const newTypes = effectiveSwaggerMediaTypes(newDoc, newOperation, field);
+ if (!oldTypes || !newTypes) return;
+ const removed = missing(oldTypes, newTypes).sort();
+ const added = missing(newTypes, oldTypes).sort();
+ const where = pointer(base, field);
+ if (removed.length) out.push(finding('breaking', kind, where, `Swagger 2 有效 ${label} 已移除内容类型:${removed.join('、')}`));
+ if (added.length) out.push(finding('non-breaking', kind, where, `Swagger 2 有效 ${label} 新增内容类型:${added.join('、')}`));
+}
+function compareSwaggerMediaTypes(oldDoc, newDoc, oldOperation, newOperation, base, out) {
+ compareSwaggerMediaTypeSet(oldDoc, newDoc, oldOperation, newOperation, base, 'consumes', 'requestBody.content', 'consumes', out);
+ compareSwaggerMediaTypeSet(oldDoc, newDoc, oldOperation, newOperation, base, 'produces', 'response.content', 'produces', out);
+}
+
+export function compareContracts(oldDoc, newDoc, collector) {
+ const target = collector && typeof collector.push === 'function' ? collector : [];
+ let findingCount = 0;
+ const out = { push(item) {
+ if (++findingCount > MAX_FINDINGS) throw Error(`契约差异数量超过 ${MAX_FINDINGS} 条限制`);
+ return target.push(item);
+ } };
+ const oldPaths = oldDoc.paths || {}, newPaths = newDoc.paths || {};
+ for (const [route, oldPath] of Object.entries(oldPaths)) {
+ if (!newPaths[route]) { out.push(finding('breaking', 'endpoint', pointer('paths', route), '接口端点已移除')); continue; }
+ const oldPathItem = resolve(oldPath, oldDoc), newPathItem = resolve(newPaths[route], newDoc);
+ for (const method of METHODS) {
+ const oldOperation = oldPathItem[method], newOperation = newPathItem[method];
+ if (!oldOperation) continue;
+ const base = pointer('paths', route, method);
+ if (!newOperation) { out.push(finding('breaking', 'method', base, '请求方法已移除')); continue; }
+ const oldParameters = parameters(oldOperation, oldPathItem, oldDoc), newParameters = parameters(newOperation, newPathItem, newDoc);
+ const nextByKey = new Map(newParameters.map((item) => [`${item.in}:${item.name}`, item]));
+ const oldKeys = new Set(oldParameters.map((item) => `${item.in}:${item.name}`));
+ for (const parameter of oldParameters) {
+ const key = `${parameter.in}:${parameter.name}`, next = nextByKey.get(key), where = pointer(base, 'parameters', key);
+ if (!next) out.push(finding('breaking', 'parameter', where, `参数 ${key} 已移除`));
+ else {
+ if (!parameter.required && next.required) out.push(finding('breaking', 'parameter.required', where, `参数 ${key} 变为必填`));
+ compareSchema(schemaForParameter(parameter), schemaForParameter(next), pointer(where, 'schema'), out, oldDoc, newDoc, 'request');
+ }
+ }
+ for (const parameter of newParameters) {
+ const key = `${parameter.in}:${parameter.name}`;
+ if (!oldKeys.has(key)) out.push(finding(parameter.required ? 'breaking' : 'non-breaking', 'parameter', pointer(base, 'parameters', key), parameter.required ? `新增必填参数 ${key}` : `新增可选参数 ${key}`));
+ }
+ compareSecurity(oldDoc, newDoc, oldOperation, newOperation, base, out);
+ compareSwaggerMediaTypes(oldDoc, newDoc, oldOperation, newOperation, base, out);
+ compareRequestBody(oldOperation.requestBody, newOperation.requestBody, pointer(base, 'requestBody'), out, oldDoc, newDoc);
+ for (const [status, oldResponse] of Object.entries(oldOperation.responses || {})) {
+ const next = newOperation.responses?.[status];
+ if (!next) out.push(finding('breaking', 'response', pointer(base, 'responses', status), `响应 ${status} 已移除`));
+ else compareResponse(oldResponse, next, pointer(base, 'responses', status), out, oldDoc, newDoc);
+ }
+ }
+ for (const method of METHODS) {
+ if (!oldPathItem[method] && newPathItem[method]) {
+ out.push(finding('non-breaking', 'method', pointer('paths', route, method), `新增请求方法 ${method.toUpperCase()}`));
+ }
+ }
+ }
+ for (const [route, pathItem] of Object.entries(newPaths)) if (!oldPaths[route]) out.push(finding('non-breaking', 'endpoint', pointer('paths', route), `新增接口端点(${Object.keys(pathItem).filter((key) => METHODS.includes(key)).join('、')})`));
+ return target;
+}
+
+function escapeMarkdown(value) { return String(value).replace(/[\\`*_{}\[\]<>]/g, '\\$&').replace(/\r?\n/g, ' '); }
+const HUMAN_FINDING_KINDS = Object.freeze({
+ endpoint: '接口端点',
+ method: '请求方法',
+ parameter: '参数',
+ 'parameter.required': '参数必填性',
+ response: '响应',
+ security: '安全要求',
+ 'requestBody.required': '请求体必填性',
+ 'requestBody.content': '请求体内容类型',
+ 'response.content': '响应内容类型',
+ 'schema.type': '数据类型',
+ 'schema.enum': '枚举范围',
+ 'schema.required': '必填字段',
+ 'schema.property': '对象属性',
+ 'schema.additionalProperties': '附加属性策略',
+ 'schema.boolean': '布尔结构定义',
+ 'schema.inconclusive': '无法确定的结构约束',
+ 'schema.minLength': '最小长度',
+ 'schema.minimum': '最小值',
+ 'schema.exclusiveMinimum': '排他最小值',
+ 'schema.minItems': '最少元素数',
+ 'schema.minProperties': '最少属性数',
+ 'schema.maxLength': '最大长度',
+ 'schema.maximum': '最大值',
+ 'schema.exclusiveMaximum': '排他最大值',
+ 'schema.maxItems': '最多元素数',
+ 'schema.maxProperties': '最多属性数',
+ 'schema.nullable': '可空性',
+ 'schema.const': '常量值',
+ 'schema.pattern': '正则模式',
+ 'schema.format': '格式约束',
+ 'schema.multipleOf': '倍数约束',
+ 'schema.uniqueItems': '元素唯一性',
+ 'schema.items': '数组元素'
+});
+export function humanFindingKind(value) { return HUMAN_FINDING_KINDS[String(value ?? '')] || '未分类变更'; }
+export function reportMarkdown(findings) {
+ const groups = ['breaking', 'non-breaking', 'info'];
+ const groupLabels = { breaking: '破坏性变更', 'non-breaking': '兼容性变更', info: '信息' };
+ return ['# OpenAPI 契约门禁报告', '', ...groups.flatMap((group) => {
+ const items = findings.filter((item) => item.level === group).map((item) => `- **${escapeMarkdown(humanFindingKind(item.kind))}**,位置 \`${escapeMarkdown(item.pointer)}\`:${escapeMarkdown(item.reason)}`);
+ return [`## ${groupLabels[group]}`, ...(items.length ? items : ['- 无'])];
+ })].join('\n');
+}
diff --git a/plugins/openapi-contract-gate/src/main/app.js b/plugins/openapi-contract-gate/src/main/app.js
new file mode 100644
index 00000000..4a9b5e82
--- /dev/null
+++ b/plugins/openapi-contract-gate/src/main/app.js
@@ -0,0 +1,118 @@
+import { createFindingPageCollector, humanFindingKind, parseDocument, compareContracts, reportMarkdown } from '../core/contract.js';
+
+const UI_PAGE_SIZE = 100;
+const ledger = document.querySelector('#ledger');
+const $ = (selector) => document.querySelector(selector);
+const levelLabels = { breaking: '破坏性变更', 'non-breaking': '兼容性变更', info: '信息' };
+let comparison = null;
+let currentPage = null;
+
+function entry(finding) {
+ const article = document.createElement('article');
+ const title = document.createElement('b');
+ const firstBreak = document.createElement('br');
+ const pointer = document.createElement('code');
+ const secondBreak = document.createElement('br');
+ article.className = `entry ${finding.level}`;
+ title.textContent = `${levelLabels[finding.level] || '未知级别'} · ${humanFindingKind(finding.kind)}`;
+ pointer.textContent = finding.pointer;
+ article.append(title, firstBreak, pointer, secondBreak, document.createTextNode(finding.reason));
+ return article;
+}
+
+function setResultControls(enabled) {
+ $('#copy-md').disabled = !enabled;
+ $('#copy-json').disabled = !enabled;
+ const total = currentPage?.counts.total || 0;
+ const offset = currentPage?.offset || 0;
+ const returned = currentPage?.findings.length || 0;
+ $('#previous-page').disabled = !enabled || offset === 0;
+ $('#next-page').disabled = !enabled || offset + returned >= total;
+ $('#finding-summary').textContent = enabled
+ ? total === 0 ? '未发现影响行为的差异。' : `显示第 ${offset + 1}—${offset + returned} 条,共 ${total} 条`
+ : '尚未执行比较';
+}
+
+function clearComparison(message = '请输入或选择两份契约后执行比较。') {
+ comparison = null;
+ currentPage = null;
+ setResultControls(false);
+ ledger.replaceChildren(Object.assign(document.createElement('article'), { className: 'entry info', textContent: message }));
+}
+
+function render() {
+ const items = currentPage.findings.length
+ ? currentPage.findings.map(entry)
+ : [Object.assign(document.createElement('article'), { className: 'entry non-breaking', textContent: '未发现影响行为的差异。' })];
+ ledger.replaceChildren(...items);
+ setResultControls(true);
+}
+
+function comparePage(offset = 0) {
+ const collector = createFindingPageCollector(offset, UI_PAGE_SIZE);
+ compareContracts(comparison.before, comparison.after, collector);
+ currentPage = collector;
+ render();
+}
+
+function run(beforeText, afterText) {
+ const before = parseDocument(beforeText);
+ const after = parseDocument(afterText);
+ comparison = { before, after };
+ comparePage(0);
+}
+
+function visibleError(error) {
+ const message = String(error?.message || '');
+ return /[\u3400-\u9fff]/.test(message) ? message : '契约处理失败,请检查输入。';
+}
+
+function showError(error) {
+ comparison = null;
+ currentPage = null;
+ setResultControls(false);
+ ledger.replaceChildren(Object.assign(document.createElement('article'), { className: 'entry', textContent: visibleError(error) }));
+}
+
+$('#compare').onclick = () => {
+ try { run($('#old').value, $('#next').value); }
+ catch (error) { showError(error); }
+};
+
+$('#copy-md').onclick = () => {
+ if (currentPage) window.contractGate?.copyText?.(reportMarkdown(currentPage.findings));
+};
+
+$('#copy-json').onclick = () => {
+ if (currentPage) window.contractGate?.copyText?.(JSON.stringify({ counts: currentPage.counts, page: { offset: currentPage.offset, limit: currentPage.limit }, findings: currentPage.findings }, null, 2));
+};
+
+$('#previous-page').onclick = () => {
+ if (!comparison || !currentPage) return;
+ try { comparePage(Math.max(0, currentPage.offset - UI_PAGE_SIZE)); }
+ catch (error) { showError(error); }
+};
+
+$('#next-page').onclick = () => {
+ if (!comparison || !currentPage) return;
+ try { comparePage(currentPage.offset + UI_PAGE_SIZE); }
+ catch (error) { showError(error); }
+};
+
+for (const input of [$('#old'), $('#next')]) input.addEventListener('input', () => clearComparison('输入已更改,请重新执行比较。'));
+
+$('#choose').onclick = async () => {
+ try {
+ const selected = await window.contractGate?.choose?.();
+ if (!selected) throw Error('ZTools 能力桥不可用');
+ if (selected.length === 0) { clearComparison('未选择契约文件。'); return; }
+ const documents = window.contractGate?.readGranted?.();
+ if (!documents) throw Error('ZTools 能力桥不可用');
+ clearComparison('已载入契约;请补齐两份后执行比较。');
+ if (documents[0]) $('#old').value = documents[0];
+ if (documents[1]) $('#next').value = documents[1];
+ if (documents.length === 2) run(documents[0], documents[1]);
+ } catch (error) { showError(error); }
+};
+
+clearComparison();
diff --git a/plugins/openapi-contract-gate/src/main/index.html b/plugins/openapi-contract-gate/src/main/index.html
new file mode 100644
index 00000000..6b74cb6d
--- /dev/null
+++ b/plugins/openapi-contract-gate/src/main/index.html
@@ -0,0 +1 @@
+
OpenAPI 契约门禁
diff --git a/plugins/openapi-contract-gate/src/main/style.css b/plugins/openapi-contract-gate/src/main/style.css
new file mode 100644
index 00000000..c8371c1d
--- /dev/null
+++ b/plugins/openapi-contract-gate/src/main/style.css
@@ -0,0 +1 @@
+:root{background:#f0eddf;color:#17271d;font-family:ui-serif,Georgia,serif}*{box-sizing:border-box}body{margin:0;background:linear-gradient(90deg,#e6e1cf 1px,transparent 1px),#f0eddf;background-size:28px 28px}main{max-width:1100px;margin:auto;padding:clamp(25px,6vw,76px)}header{display:flex;justify-content:space-between;align-items:center;border-bottom:3px solid #1d623d;padding-bottom:20px}small{letter-spacing:.16em;color:#6d653c;font:12px ui-monospace,monospace}h1{margin:5px 0;font-size:clamp(30px,5vw,58px);font-weight:600}h1,small,label{overflow-wrap:break-word;text-wrap:pretty}.seal{width:66px;height:66px;border:3px double #1d623d;border-radius:50%;display:grid;place-items:center;color:#1d623d;font:bold 15px ui-monospace,monospace}.books{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:34px 0 17px}.books>*{min-width:0}label{display:grid;gap:8px;font:13px ui-monospace,monospace;color:#625d38}textarea{width:100%;min-width:0;min-height:240px;padding:13px;background:#fffdf4;border:1px solid #b5ac7b;color:#17271d;font:12px ui-monospace,monospace}.actions{display:flex;flex-wrap:wrap;gap:8px}.pager{min-width:0;margin-top:18px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;font:13px ui-monospace,monospace}.pager span{margin-right:auto;overflow-wrap:anywhere}button{max-width:100%;border:1px solid #1d623d;background:#1d623d;color:white;padding:12px 17px;font-weight:bold;cursor:pointer;white-space:normal;overflow-wrap:break-word}button:disabled{cursor:not-allowed;opacity:.48}#ledger{min-width:0;margin-top:18px;display:grid;gap:8px}.entry{min-width:0;border-left:5px solid #ca5b3c;background:#fffdf4;padding:13px;font:14px ui-monospace,monospace;overflow-wrap:anywhere}.entry code{overflow-wrap:anywhere;word-break:break-word}.non-breaking{border-left-color:#1d623d}.info{border-left-color:#c18a29}button:focus-visible,textarea:focus-visible{outline:3px solid #ef9b38;outline-offset:3px}@media(max-width:650px){.books{grid-template-columns:1fr}.seal{display:none}}@media(prefers-reduced-motion:reduce){*{transition:none!important}}
diff --git a/plugins/openapi-contract-gate/src/preload/index.cjs b/plugins/openapi-contract-gate/src/preload/index.cjs
new file mode 100644
index 00000000..32620276
--- /dev/null
+++ b/plugins/openapi-contract-gate/src/preload/index.cjs
@@ -0,0 +1,408 @@
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const YAML = require('yaml');
+const MAX = 10 * 1024 * 1024, TTL = 300000, DEPTH = 60, NODES = 40000;
+const INLINE_EACH_MAX = 320 * 1024, INLINE_TOTAL_MAX = 640 * 1024, MAX_PAGE = 200;
+const MCP_RESPONSE_MAX = 512 * 1024, MCP_MARKDOWN_MAX = 64 * 1024;
+const FINDING_FIELD_MAX = Object.freeze({ level: 32, kind: 128, pointer: 1024, reason: 768 });
+const TOOL_NAMES = Object.freeze({ compareInline: 'compare_inline', compareApprovedFiles: 'compare_approved_files' });
+const CLOSE_RETRY_MS = 100;
+const registeredHosts = new WeakSet();
+let grants = [];
+let pendingCloses = [];
+let grantTimer;
+let closeRetryTimer;
+let sessionEpoch = 0;
+let closeSync = fs.closeSync;
+
+function sessionExpired() {
+ return Object.assign(new Error('插件会话已结束,请重新打开后选择契约文件。'), { code: 'SESSION_EXPIRED' });
+}
+function assertSessionEpoch(epoch) {
+ if (epoch !== sessionEpoch) throw sessionExpired();
+}
+
+function close(record) {
+ if (!record || record.closed) return true;
+ try {
+ closeSync(record.fd);
+ record.closed = true;
+ return true;
+ } catch (error) {
+ if (error?.code === 'EBADF') {
+ record.closed = true;
+ return true;
+ }
+ record.closeFailed = true;
+ return false;
+ }
+}
+function scheduleCloseRetry() {
+ if (!pendingCloses.length || closeRetryTimer) return;
+ closeRetryTimer = setTimeout(() => {
+ closeRetryTimer = undefined;
+ retryPendingCloses();
+ }, CLOSE_RETRY_MS);
+ closeRetryTimer.unref?.();
+}
+function retire(record) {
+ if (!record) return;
+ record.revoked = true;
+ if (!close(record) && !pendingCloses.some((pending) => pending.fd === record.fd)) {
+ // The retry queue deliberately retains only the descriptor. Authorization
+ // paths and file metadata are revoked even when the operating system asks
+ // us to retry closing the handle.
+ pendingCloses.push({ fd: record.fd, closed: false, closeFailed: true });
+ scheduleCloseRetry();
+ }
+}
+function retryPendingCloses() {
+ if (closeRetryTimer) clearTimeout(closeRetryTimer);
+ closeRetryTimer = undefined;
+ const active = pendingCloses;
+ pendingCloses = [];
+ for (const record of active) {
+ if (!close(record)) pendingCloses.push(record);
+ }
+ scheduleCloseRetry();
+}
+function clear() {
+ if (grantTimer) clearTimeout(grantTimer);
+ grantTimer = undefined;
+ // Grant replacement, TTL expiry, plugin-out, and explicit clearing are all
+ // immediate retry opportunities. The timer is only a fallback.
+ retryPendingCloses();
+ const active = grants;
+ grants = [];
+ for (const record of active) retire(record);
+}
+function expireSession() {
+ sessionEpoch += 1;
+ clear();
+}
+function scheduleClear() {
+ if (!grants.length) return;
+ const delay = Math.max(0, Math.min(...grants.map((item) => item.until)) - Date.now());
+ grantTimer = setTimeout(clear, delay);
+ grantTimer.unref?.();
+}
+function audit(root) {
+ const queue = [[root, 0]], seen = new Set(); let nodes = 0;
+ while (queue.length) {
+ const [value, depth] = queue.pop();
+ if (depth > DEPTH || ++nodes > NODES) throw Error('契约超过安全结构限制');
+ if (value === null || typeof value !== 'object') continue;
+ if (seen.has(value)) continue;
+ seen.add(value);
+ for (const [key, next] of Object.entries(value)) {
+ if (key === '$ref' && typeof next === 'string' && !next.startsWith('#/')) throw Error('不允许远程 $ref');
+ queue.push([next, depth + 1]);
+ }
+ }
+}
+function isPlainObject(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ const prototype = Object.getPrototypeOf(value);
+ return prototype === Object.prototype || prototype === null;
+}
+function validateDocumentShape(document) {
+ const openapi3 = typeof document?.openapi === 'string' && /^3\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(document.openapi);
+ const swagger2 = document?.swagger === '2.0';
+ if (!isPlainObject(document) || (!openapi3 && !swagger2)) throw Error('根节点必须是 OpenAPI 3.x 或 Swagger 2.0 契约');
+ if (!isPlainObject(document.paths)) throw Error('paths 必须是普通对象');
+}
+function sameIdentity(record, stat) {
+ return stat.isFile() && stat.size === record.size && stat.mtimeMs === record.mtime && stat.ctimeMs === record.ctime && stat.dev === record.dev && stat.ino === record.ino;
+}
+function readStableBuffer(record, expectedDigest) {
+ const before = fs.fstatSync(record.fd);
+ if (!sameIdentity(record, before)) throw Error('契约文件在选择后发生变化');
+ const buffer = Buffer.alloc(record.size); let offset = 0;
+ while (offset < buffer.length) {
+ const length = Math.min(64 * 1024, buffer.length - offset);
+ const count = fs.readSync(record.fd, buffer, offset, length, offset);
+ if (!count) throw Error('契约文件读取不完整');
+ offset += count;
+ }
+ const after = fs.fstatSync(record.fd);
+ if (!sameIdentity(record, after)) throw Error('契约文件在读取期间发生变化');
+ const digest = crypto.createHash('sha256').update(buffer).digest('hex');
+ if (expectedDigest && digest !== expectedDigest) throw Error('契约文件内容与授权时不一致');
+ return { buffer, digest };
+}
+function record(file, ttl) {
+ const real = fs.realpathSync(file), link = fs.lstatSync(file);
+ const fd = fs.openSync(real, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
+ try {
+ const stat = fs.fstatSync(fd);
+ if (link.isSymbolicLink() || !stat.isFile() || stat.size > MAX || !/\.(json|ya?ml)$/i.test(real)) throw Error('契约文件不符合安全要求');
+ const selected = { fd, real, size: stat.size, mtime: stat.mtimeMs, ctime: stat.ctimeMs, dev: stat.dev, ino: stat.ino, until: Date.now() + ttl };
+ selected.digest = readStableBuffer(selected).digest;
+ return selected;
+ } catch (error) { retire({ fd }); throw error; }
+}
+function grant(files, ttl = TTL) {
+ clear();
+ if (!Array.isArray(files) || files.length < 1 || files.length > 2) throw Error('请选择一到两个契约文件');
+ if (!Number.isSafeInteger(ttl) || ttl < 1 || ttl > TTL) throw Error('授权有效期无效');
+ const selected = [];
+ try {
+ for (const file of files) selected.push(record(file, ttl));
+ grants = selected;
+ scheduleClear();
+ return grants.map((item) => path.basename(item.real));
+ }
+ catch (error) { for (const item of selected) retire(item); throw error; }
+}
+function auditYamlNode(node, seen = new Set()) {
+ if (!node || typeof node !== 'object' || seen.has(node)) return;
+ seen.add(node);
+ if (node.anchor || node.tag || node.constructor?.name === 'Alias') throw Error('不允许 YAML 锚点、别名或显式标签');
+ if (Array.isArray(node.items)) for (const item of node.items) auditYamlNode(item, seen);
+ auditYamlNode(node.key, seen);
+ auditYamlNode(node.value, seen);
+}
+function parseYaml(text) {
+ const document = YAML.parseDocument(text, { uniqueKeys: true, prettyErrors: false });
+ if (document.errors.length || document.warnings.length) {
+ const message = document.errors.concat(document.warnings).map((item) => item.message).join('; ');
+ if (/tag|alias|anchor/i.test(message)) throw Error('不允许 YAML 锚点、别名或显式标签');
+ throw Error('YAML 格式无效');
+ }
+ auditYamlNode(document.contents);
+ return document.toJS({ maxAliasCount: 0 });
+}
+function invalidTool(message) { const error = new Error(message); error.code = 'INVALID_TOOL_INPUT'; throw error; }
+function byteLength(value) { return Buffer.byteLength(String(value), 'utf8'); }
+function validateObject(input, allowed, label) {
+ if (!input || typeof input !== 'object' || Array.isArray(input)) invalidTool(`${label}必须是对象。`);
+ let prototype, keys;
+ try { prototype = Object.getPrototypeOf(input); keys = Reflect.ownKeys(input); } catch { invalidTool(`${label}结构无效。`); }
+ if (prototype !== Object.prototype && prototype !== null) invalidTool(`${label}原型无效。`);
+ const values = Object.create(null);
+ for (const key of keys) {
+ if (typeof key !== 'string' || key === '__proto__' || key === 'prototype' || key === 'constructor' || !allowed.has(key)) invalidTool(`${label}包含未允许字段。`);
+ let descriptor;
+ try { descriptor = Object.getOwnPropertyDescriptor(input, key); } catch { invalidTool(`${label}字段无效。`); }
+ if (!descriptor || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) invalidTool(`${label}只允许数据字段。`);
+ values[key] = descriptor.value;
+ }
+ return values;
+}
+function validatePage(input) {
+ const offset = input.offset === undefined ? 0 : input.offset;
+ const limit = input.limit === undefined ? 100 : input.limit;
+ if (!Number.isSafeInteger(offset) || offset < 0) invalidTool('offset 必须是非负安全整数。');
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PAGE) invalidTool('limit 必须是 1—200 的安全整数。');
+ if (input.includeMarkdown !== undefined && typeof input.includeMarkdown !== 'boolean') invalidTool('includeMarkdown 必须是布尔值。');
+ return { offset, limit, includeMarkdown: input.includeMarkdown === true };
+}
+function validateInlineInput(input) {
+ input = validateObject(input, new Set(['before', 'after', 'format', 'includeMarkdown', 'offset', 'limit']), '内联比较参数');
+ if (typeof input.before !== 'string' || typeof input.after !== 'string') invalidTool('before 和 after 必须是字符串。');
+ const beforeBytes = byteLength(input.before), afterBytes = byteLength(input.after);
+ if (beforeBytes > INLINE_EACH_MAX || afterBytes > INLINE_EACH_MAX || beforeBytes + afterBytes > INLINE_TOTAL_MAX) invalidTool('两份内联契约必须各不超过 320 KiB,合计不超过 640 KiB UTF-8。');
+ const format = input.format === undefined ? 'auto' : input.format;
+ if (!['auto', 'json', 'yaml'].includes(format)) invalidTool('format 必须是 auto、json 或 yaml。');
+ return { before: input.before, after: input.after, format, ...validatePage(input) };
+}
+function validateApprovedInput(input) {
+ input = validateObject(input, new Set(['includeMarkdown', 'offset', 'limit']), '授权文件比较参数');
+ return validatePage(input);
+}
+function parseInline(text, format) {
+ let document;
+ if (format === 'json') {
+ try { document = JSON.parse(text); } catch { throw Error('JSON 契约格式无效'); }
+ } else if (format === 'yaml') document = parseYaml(text);
+ else {
+ try { document = JSON.parse(text); }
+ catch { document = parseYaml(text); }
+ }
+ validateDocumentShape(document);
+ audit(document);
+ const serialized = JSON.stringify(document);
+ if (byteLength(serialized) > INLINE_EACH_MAX) invalidTool('解析后的内联契约不得超过 320 KiB UTF-8。');
+ return serialized;
+}
+function clipUtf8(value, maximum) {
+ const text = String(value ?? '');
+ const bytes = Buffer.from(text, 'utf8');
+ if (bytes.length <= maximum) return { value: text, truncated: false };
+ const suffix = Buffer.from('…');
+ let end = Math.max(0, maximum - suffix.length);
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
+ return { value: bytes.subarray(0, end).toString('utf8') + '…', truncated: true };
+}
+function createFindingCollector(page) {
+ const counts = { breaking: 0, nonBreaking: 0, info: 0, total: 0 };
+ const findings = [];
+ let fieldTruncated = false;
+ return {
+ counts,
+ findings,
+ get fieldTruncated() { return fieldTruncated; },
+ push(item) {
+ const index = counts.total;
+ counts.total += 1;
+ if (item?.level === 'breaking') counts.breaking += 1;
+ else if (item?.level === 'non-breaking') counts.nonBreaking += 1;
+ else if (item?.level === 'info') counts.info += 1;
+ if (index < page.offset || findings.length >= page.limit) return counts.total;
+ const sanitized = {};
+ for (const field of ['level', 'kind', 'pointer', 'reason']) {
+ const clipped = clipUtf8(item?.[field], FINDING_FIELD_MAX[field]);
+ sanitized[field] = clipped.value;
+ fieldTruncated ||= clipped.truncated;
+ }
+ findings.push(sanitized);
+ return counts.total;
+ }
+ };
+}
+function serializedBytes(value) { return Buffer.byteLength(JSON.stringify(value), 'utf8'); }
+function buildCollectorResponse(collector, page, mod, findingCount) {
+ const findings = collector.findings.slice(0, findingCount);
+ let markdownTruncated = false;
+ let markdown;
+ if (page.includeMarkdown) {
+ const clipped = clipUtf8(mod.reportMarkdown(findings), MCP_MARKDOWN_MAX);
+ markdown = clipped.value;
+ markdownTruncated = clipped.truncated;
+ }
+ const responseTruncated = collector.fieldTruncated || findingCount < collector.findings.length || markdownTruncated;
+ return {
+ gatePassed: collector.counts.breaking === 0,
+ counts: collector.counts,
+ findings,
+ page: { offset: page.offset, limit: page.limit, returned: findings.length, total: collector.counts.total, truncated: page.offset + findings.length < collector.counts.total },
+ responseTruncated,
+ ...(page.includeMarkdown ? { markdown, markdownTruncated } : {})
+ };
+}
+function boundedCollectorResponse(collector, page, mod) {
+ let low = 0, high = collector.findings.length, best = buildCollectorResponse(collector, page, mod, 0);
+ while (low <= high) {
+ const middle = Math.floor((low + high) / 2);
+ const candidate = buildCollectorResponse(collector, page, mod, middle);
+ if (serializedBytes(candidate) <= MCP_RESPONSE_MAX) { best = candidate; low = middle + 1; }
+ else high = middle - 1;
+ }
+ if (best.findings.length < collector.findings.length) best.responseTruncated = true;
+ if (serializedBytes(best) > MCP_RESPONSE_MAX) throw Object.assign(new Error('MCP 响应超过安全预算。'), { code: 'MCP_RESPONSE_BUDGET_EXCEEDED' });
+ return best;
+}
+function stableToolError(code, message) { return Object.assign(new Error(message), { code }); }
+async function compareSerialized(before, after, page) {
+ const mod = await import('../core/contract.js');
+ const collector = createFindingCollector(page);
+ mod.compareContracts(mod.parseDocument(before), mod.parseDocument(after), collector);
+ return boundedCollectorResponse(collector, page, mod);
+}
+async function compareInline(input) {
+ try {
+ const value = validateInlineInput(input);
+ return await compareSerialized(parseInline(value.before, value.format), parseInline(value.after, value.format), value);
+ } catch (error) {
+ if (error?.code === 'INVALID_TOOL_INPUT') throw error;
+ throw stableToolError('CONTRACT_COMPARISON_FAILED', '契约解析或比较失败;请检查格式与安全限制。');
+ }
+}
+async function compareApprovedFiles(input) {
+ const value = validateApprovedInput(input);
+ const epoch = sessionEpoch;
+ try {
+ assertSessionEpoch(epoch);
+ if (grants.length !== 2 || grants.some((item) => item.until < Date.now())) {
+ clear();
+ throw stableToolError('UI_APPROVAL_REQUIRED', '请先在插件界面一次选择两份契约文件。');
+ }
+ const documents = readGranted({ consume: true });
+ const result = await compareSerialized(documents[0], documents[1], value);
+ assertSessionEpoch(epoch);
+ return result;
+ } catch (error) {
+ clear();
+ if (error?.code === 'UI_APPROVAL_REQUIRED') throw error;
+ if (error?.code === 'SESSION_EXPIRED') throw error;
+ throw stableToolError('APPROVED_CONTRACT_FAILED', '已授权契约不可用或比较失败,请在插件界面重新选择。');
+ }
+}
+function registerTools(ztools) {
+ if (!ztools || typeof ztools.registerTool !== 'function') return false;
+ if (registeredHosts.has(ztools)) return false;
+ let registered = 0;
+ for (const [name, handler] of [
+ [TOOL_NAMES.compareInline, (input) => compareInline(input)],
+ [TOOL_NAMES.compareApprovedFiles, (input) => compareApprovedFiles(input)]
+ ]) {
+ try { ztools.registerTool.call(ztools, name, handler); registered += 1; } catch {}
+ }
+ registeredHosts.add(ztools);
+ return registered > 0;
+}
+function read(record) {
+ if (Date.now() > record.until) throw Error('文件选择授权已过期');
+ const { buffer } = readStableBuffer(record, record.digest);
+ let document;
+ if (/\.json$/i.test(record.real)) {
+ try { document = JSON.parse(buffer.toString('utf8')); } catch { throw Error('JSON 契约格式无效'); }
+ } else document = parseYaml(buffer.toString('utf8'));
+ validateDocumentShape(document);
+ audit(document);
+ const serialized = JSON.stringify(document);
+ if (Buffer.byteLength(serialized) > MAX) throw Error('契约超过安全序列化大小限制');
+ return serialized;
+}
+function readGranted({ consume = false } = {}) {
+ if (!grants.length) throw Error('请先选择契约文件');
+ try { return grants.map(read); }
+ catch (error) { clear(); throw error; }
+ finally { if (consume) clear(); }
+}
+async function choose(ztools) {
+ if (typeof ztools?.showOpenDialog !== 'function') throw Error('ZTools 文件选择对话框不可用');
+ expireSession();
+ const epoch = sessionEpoch;
+ const result = await ztools.showOpenDialog({ title: '选择一到两个 OpenAPI 契约文件', properties: ['openFile', 'multiSelections'], filters: [{ name: 'OpenAPI 契约', extensions: ['json', 'yaml', 'yml'] }] });
+ assertSessionEpoch(epoch);
+ const files = Array.isArray(result) ? result : result?.filePaths;
+ if (!files?.length) { clear(); return []; }
+ try {
+ const names = grant(files);
+ assertSessionEpoch(epoch);
+ return names;
+ }
+ catch { clear(); throw Error('无法授权所选契约文件,请检查文件类型、大小和权限'); }
+}
+function bridge(ztools) {
+ if (typeof ztools?.onPluginOut === 'function') ztools.onPluginOut(expireSession);
+ registerTools(ztools);
+ return Object.freeze({ choose: () => choose(ztools), readGranted: () => readGranted(), copyText: (text) => ztools?.copyText?.(String(text)) });
+}
+if (typeof window !== 'undefined') window.contractGate = bridge(window.ztools);
+module.exports = {
+ TOOL_NAMES,
+ bridge,
+ registerTools,
+ validateInlineInput,
+ validateApprovedInput,
+ createFindingCollector,
+ boundedCollectorResponse,
+ compareInline,
+ compareApprovedFiles,
+ __testGrant: grant,
+ __testClear: clear,
+ __testExpireSession: expireSession,
+ __testGrants: () => grants,
+ __testPendingCloses: () => pendingCloses,
+ __testRetryPendingCloses: retryPendingCloses,
+ __testSetCloseSync: (value) => {
+ if (typeof value !== 'function') throw new TypeError('测试 closeSync 必须是函数。');
+ closeSync = value;
+ },
+ __testResetCloseSync: () => { closeSync = fs.closeSync; },
+ __testSessionEpoch: () => sessionEpoch,
+ readGranted
+};
diff --git a/plugins/openapi-contract-gate/test/contract.test.mjs b/plugins/openapi-contract-gate/test/contract.test.mjs
new file mode 100644
index 00000000..35c3d67e
--- /dev/null
+++ b/plugins/openapi-contract-gate/test/contract.test.mjs
@@ -0,0 +1,658 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { createRequire } from 'node:module';
+import { fileURLToPath } from 'node:url';
+import { compareContracts, createFindingPageCollector, humanFindingKind, parseDocument, pathContract, reportMarkdown } from '../src/core/contract.js';
+
+const require = createRequire(import.meta.url);
+const preload = require('../src/preload/index.cjs');
+const doc = (operation, extra = {}) => ({ openapi: '3.1.0', paths: { '/pets/{id}': { parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], get: operation } }, ...extra });
+const operation = (overrides = {}) => ({ responses: { 200: { content: { 'application/json': { schema: { type: 'string', enum: ['ok'] } } } } }, ...overrides });
+const has = (findings, kind) => findings.some((item) => item.kind === kind && item.level === 'breaking');
+
+test('manifest declarations and preload registrations use the same short MCP names', () => {
+ const calls = new Map();
+ preload.bridge({ registerTool(name, handler) { calls.set(name, handler); } });
+ const manifest = JSON.parse(fs.readFileSync(new URL('../plugin.json', import.meta.url), 'utf8'));
+ assert.deepEqual(Object.keys(manifest.tools).sort(), Object.values(preload.TOOL_NAMES).sort());
+ assert.deepEqual([...calls.keys()].sort(), Object.values(preload.TOOL_NAMES).sort());
+ assert.ok([...calls.values()].every((handler) => typeof handler === 'function'));
+});
+
+test('legacy hosts without registerTool retain the renderer bridge', () => {
+ const renderer = preload.bridge({});
+ assert.equal(typeof renderer.choose, 'function');
+ assert.equal(typeof renderer.readGranted, 'function');
+});
+
+test('one registerTool failure does not block the renderer bridge or remaining tool', () => {
+ const registered = [];
+ const renderer = preload.bridge({ registerTool(name) { if (name === preload.TOOL_NAMES.compareInline) throw Error('one failure'); registered.push(name); } });
+ assert.deepEqual(registered, [preload.TOOL_NAMES.compareApprovedFiles]);
+ assert.equal(typeof renderer.choose, 'function');
+});
+
+test('inline MCP comparison returns deterministic full counts and paginated evidence', async () => {
+ const calls = new Map();
+ preload.bridge({ registerTool(name, handler) { calls.set(name, handler); } });
+ const before = JSON.stringify({ openapi: '3.1.0', paths: { '/pets': { get: operation() } } });
+ const after = JSON.stringify({ openapi: '3.1.0', paths: {} });
+ const result = await calls.get(preload.TOOL_NAMES.compareInline)({ before, after, format: 'json', includeMarkdown: true, offset: 0, limit: 1 });
+ assert.equal(result.gatePassed, false);
+ assert.equal(result.counts.breaking, 1);
+ assert.equal(result.counts.total, 1);
+ assert.equal(result.findings.length, 1);
+ assert.equal(result.findings[0].kind, 'endpoint');
+ assert.match(result.markdown, /接口端点/);
+ assert.doesNotMatch(result.markdown, /endpoint/);
+});
+
+test('inline MCP comparison rejects hostile fields and byte or page overflow before parsing', async () => {
+ const valid = JSON.stringify({ openapi: '3.1.0', paths: {} });
+ await assert.rejects(preload.compareInline({ before: valid, after: valid, command: 'write' }), (error) => error.code === 'INVALID_TOOL_INPUT');
+ const hostile = JSON.parse(`{"before":${JSON.stringify(valid)},"after":${JSON.stringify(valid)},"__proto__":{"polluted":true}}`);
+ await assert.rejects(preload.compareInline(hostile), (error) => error.code === 'INVALID_TOOL_INPUT');
+ await assert.rejects(preload.compareInline({ before: '你'.repeat(110000), after: valid }), /320 KiB/);
+ await assert.rejects(preload.compareInline({ before: valid, after: valid, limit: 201 }), /1—200/);
+ let getterCalled = false;
+ const accessor = { after: valid };
+ Object.defineProperty(accessor, 'before', { enumerable: true, get() { getterCalled = true; return valid; } });
+ await assert.rejects(preload.compareInline(accessor), (error) => error.code === 'INVALID_TOOL_INPUT');
+ assert.equal(getterCalled, false);
+ const symbolInput = { before: valid, after: valid }; symbolInput[Symbol('hidden')] = true;
+ await assert.rejects(preload.compareInline(symbolInput), (error) => error.code === 'INVALID_TOOL_INPUT');
+});
+
+test('MCP comparison streams a half-MiB long-pointer attack into a bounded response', async () => {
+ const route = `/${'r'.repeat(260000)}`;
+ const parameters = Array.from({ length: 200 }, (_, index) => ({ name: `q${index}`, in: 'query', schema: { type: 'string' } }));
+ const before = JSON.stringify({ openapi: '3.1.0', paths: { [route]: { get: operation({ parameters }) } } });
+ const after = JSON.stringify({ openapi: '3.1.0', paths: { [route]: { get: operation() } } });
+ const requestBytes = Buffer.byteLength(before) + Buffer.byteLength(after);
+ assert.ok(requestBytes > 500 * 1024 && requestBytes < 640 * 1024);
+ const rssBefore = process.memoryUsage().rss;
+ const result = await preload.compareInline({ before, after, format: 'json', includeMarkdown: true, offset: 0, limit: 200 });
+ const rssGrowth = Math.max(0, process.memoryUsage().rss - rssBefore);
+ assert.equal(result.counts.breaking, 200);
+ assert.equal(result.counts.total, 200);
+ assert.equal(result.gatePassed, false);
+ assert.equal(result.responseTruncated, true);
+ assert.ok(result.findings.every((item) => Buffer.byteLength(item.pointer) <= 1024 && Buffer.byteLength(item.reason) <= 768));
+ assert.ok(Buffer.byteLength(JSON.stringify(result)) <= 512 * 1024);
+ assert.ok(rssGrowth < 160 * 1024 * 1024, `RSS grew by ${rssGrowth} bytes`);
+ const source = fs.readFileSync(new URL('../src/preload/index.cjs', import.meta.url), 'utf8');
+ assert.match(source, /compareContracts\([^;]+collector\)/);
+ assert.doesNotMatch(source, /const findings = mod\.compareContracts/);
+});
+
+test('approved-file MCP comparison consumes exactly the UI grants and accepts no paths', async () => {
+ const fixture = fileURLToPath(new URL('./fixtures/openapi.yaml', import.meta.url));
+ const calls = new Map();
+ preload.bridge({ registerTool(name, handler) { calls.set(name, handler); } });
+ preload.__testClear();
+ await assert.rejects(calls.get(preload.TOOL_NAMES.compareApprovedFiles)({}), (error) => error.code === 'UI_APPROVAL_REQUIRED');
+ preload.__testGrant([fixture, fixture]);
+ const documents = preload.readGranted();
+ assert.equal(documents.length, 2);
+ assert.equal(preload.__testGrants().length, 2, 'human UI read must preserve the latest two-file MCP grant');
+ const result = await calls.get(preload.TOOL_NAMES.compareApprovedFiles)({ offset: 0, limit: 10 });
+ assert.equal(result.gatePassed, true);
+ assert.equal(result.counts.total, 0);
+ assert.equal(preload.__testGrants().length, 0);
+ preload.__testGrant([fixture]);
+ await assert.rejects(calls.get(preload.TOOL_NAMES.compareApprovedFiles)({}), (error) => error.code === 'UI_APPROVAL_REQUIRED');
+ assert.equal(preload.__testGrants().length, 0);
+ preload.__testGrant([fixture, fixture]);
+ preload.__testGrants()[0].until = 0;
+ await assert.rejects(calls.get(preload.TOOL_NAMES.compareApprovedFiles)({}), (error) => error.code === 'UI_APPROVAL_REQUIRED');
+ assert.equal(preload.__testGrants().length, 0);
+ await assert.rejects(calls.get(preload.TOOL_NAMES.compareApprovedFiles)({ path: fixture }), (error) => error.code === 'INVALID_TOOL_INPUT');
+});
+
+test('approved-file MCP failures are stable and never expose a changed file path', async () => {
+ const directory = fs.mkdtempSync(path.join(process.cwd(), 'test', 'openapi-mcp-failure-'));
+ const before = path.join(directory, 'before.json'), after = path.join(directory, 'after.json');
+ const value = JSON.stringify({ openapi: '3.1.0', paths: {} });
+ fs.writeFileSync(before, value); fs.writeFileSync(after, value);
+ preload.__testGrant([before, after]);
+ fs.appendFileSync(before, ' ');
+ await assert.rejects(preload.compareApprovedFiles({}), (error) => {
+ assert.equal(error.code, 'APPROVED_CONTRACT_FAILED');
+ assert.equal(error.message.includes(directory), false);
+ assert.equal(error.message.includes(before), false);
+ return true;
+ });
+ assert.equal(preload.__testGrants().length, 0);
+ fs.rmSync(directory, { recursive: true, force: true });
+});
+
+test('plugin exit prevents an in-flight approved comparison from returning a stale result', async () => {
+ const fixture = fileURLToPath(new URL('./fixtures/openapi.yaml', import.meta.url));
+ preload.__testGrant([fixture, fixture]);
+ const selected = [...preload.__testGrants()];
+ const pending = preload.compareApprovedFiles({ offset: 0, limit: 10 });
+ preload.__testExpireSession();
+ await assert.rejects(pending, (error) => error?.code === 'SESSION_EXPIRED');
+ assert.equal(preload.__testGrants().length, 0);
+ for (const item of selected) assert.throws(() => fs.fstatSync(item.fd), { code: 'EBADF' });
+});
+
+test('request and response variance has opposite enum/type directions', () => {
+ const before = doc(operation({ parameters: [{ name: 'q', in: 'query', schema: { type: ['string', 'null'], enum: ['a', 'b'] } }], requestBody: { content: { 'application/json': { schema: { type: 'string', enum: ['a', 'b'] } } } } }));
+ const after = doc(operation({ parameters: [{ name: 'q', in: 'query', schema: { type: 'string', enum: ['a'] } }], requestBody: { content: { 'application/json': { schema: { type: 'string', enum: ['a'] } } } }, responses: { 200: { content: { 'application/json': { schema: { type: ['string', 'null'], enum: ['ok', 'unknown'] } } } } } }));
+ const findings = compareContracts(before, after);
+ assert.ok(has(findings, 'schema.type'));
+ assert.ok(has(findings, 'schema.enum'));
+});
+
+test('query header path and cookie schemas are compared', () => {
+ const before = doc(operation({ parameters: ['query', 'header', 'path', 'cookie'].map((location) => ({ name: location === 'path' ? 'id' : location, in: location, required: location === 'path', schema: { type: 'string', enum: ['a', 'b'] } })) }));
+ const after = doc(operation({ parameters: ['query', 'header', 'path', 'cookie'].map((location) => ({ name: location === 'path' ? 'id' : location, in: location, required: location === 'path', schema: { type: 'string', enum: ['a'] } })) }));
+ assert.ok(compareContracts(before, after).filter((item) => item.kind === 'schema.enum').length >= 4);
+});
+
+test('Swagger 2 non-body parameter type and enum are compared directly', () => {
+ const before = { swagger: '2.0', paths: { '/pets/{id}': { parameters: [{ name: 'id', in: 'path', required: true, type: 'string' }], get: { parameters: [{ name: 'q', in: 'query', type: 'string', enum: ['a', 'b'] }], responses: { 200: { schema: { type: 'string' } } } } } } };
+ const after = { swagger: '2.0', paths: { '/pets/{id}': { parameters: [{ name: 'id', in: 'path', required: true, type: 'string' }], get: { parameters: [{ name: 'q', in: 'query', type: 'string', enum: ['a'] }], responses: { 200: { schema: { type: 'string' } } } } } } };
+ assert.ok(has(compareContracts(before, after), 'schema.enum'));
+});
+
+test('Swagger 2 global consumes and produces compare effective media type sets', () => {
+ const swaggerOperation = { responses: { 200: { schema: { type: 'string' } } } };
+ const before = { swagger: '2.0', consumes: ['application/json', 'application/xml'], produces: ['application/json'], paths: { '/pets': { post: swaggerOperation } } };
+ const after = { swagger: '2.0', consumes: ['application/xml', 'text/plain'], produces: ['application/xml'], paths: { '/pets': { post: structuredClone(swaggerOperation) } } };
+ const findings = compareContracts(before, after);
+ assert.ok(findings.some((item) => item.level === 'breaking' && item.kind === 'requestBody.content' && item.reason.includes('application/json')));
+ assert.ok(findings.some((item) => item.level === 'non-breaking' && item.kind === 'requestBody.content' && item.reason.includes('text/plain')));
+ assert.ok(findings.some((item) => item.level === 'breaking' && item.kind === 'response.content' && item.reason.includes('application/json')));
+ assert.ok(findings.some((item) => item.level === 'non-breaking' && item.kind === 'response.content' && item.reason.includes('application/xml')));
+ assert.ok(findings.every((item) => item.pointer.startsWith('/paths/~1pets/post/')));
+ const markdown = reportMarkdown(findings);
+ assert.match(markdown, /请求体内容类型/);
+ assert.match(markdown, /响应内容类型/);
+ assert.doesNotMatch(markdown, /requestBody\.content|response\.content/);
+});
+
+test('Swagger 2 operation media types override globals while absent fields inherit them', () => {
+ const response = { responses: { 200: { schema: { type: 'string' } } } };
+ const before = { swagger: '2.0', consumes: ['application/json'], produces: ['application/json'], paths: { '/pets': { get: { ...structuredClone(response), consumes: ['text/plain'], produces: ['text/plain'] }, post: structuredClone(response) } } };
+ const after = { swagger: '2.0', consumes: ['application/xml'], produces: ['application/xml'], paths: { '/pets': { get: { ...structuredClone(response), consumes: ['text/plain'], produces: ['text/plain'] }, post: structuredClone(response) } } };
+ const findings = compareContracts(before, after).filter((item) => item.kind === 'requestBody.content' || item.kind === 'response.content');
+ assert.equal(findings.length, 4);
+ assert.ok(findings.every((item) => item.pointer.startsWith('/paths/~1pets/post/')));
+ assert.equal(findings.some((item) => item.pointer.includes('/get/')), false);
+});
+
+test('Swagger 2 inherited and operation-level declarations with equal effective sets do not differ', () => {
+ const response = { responses: { 200: { schema: { type: 'string' } } } };
+ const before = { swagger: '2.0', consumes: ['application/json'], produces: ['application/json'], paths: { '/pets': { post: structuredClone(response) } } };
+ const after = { swagger: '2.0', consumes: ['application/xml'], produces: ['application/xml'], paths: { '/pets': { post: { ...structuredClone(response), consumes: ['application/json'], produces: ['application/json'] } } } };
+ const findings = compareContracts(before, after);
+ assert.equal(findings.some((item) => item.kind === 'requestBody.content' || item.kind === 'response.content'), false);
+});
+
+test('OpenAPI 3 ignores Swagger consumes and produces compatibility keywords', () => {
+ const before = doc(operation(), { consumes: ['application/json'], produces: ['application/json'] });
+ const after = doc(operation(), { consumes: ['application/xml'], produces: ['application/xml'] });
+ const findings = compareContracts(before, after);
+ assert.equal(findings.some((item) => item.kind === 'requestBody.content' || item.kind === 'response.content'), false);
+});
+
+test('request body required and content removal are breaking', () => {
+ const before = doc(operation({ requestBody: { content: { 'application/json': { schema: { type: 'object' } }, 'application/xml': { schema: { type: 'object' } } } } }));
+ const after = doc(operation({ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object' } } } } }));
+ const findings = compareContracts(before, after);
+ assert.ok(has(findings, 'requestBody.required'));
+ assert.ok(has(findings, 'requestBody.content'));
+ assert.ok(has(compareContracts(before, doc(operation())), 'requestBody.content'));
+});
+
+test('response compares matching media types and required output guarantees', () => {
+ const before = doc(operation({ responses: { 200: { content: { 'application/json': { schema: { type: 'object', required: ['id'], properties: { id: { type: 'string', enum: ['a'] } } } }, 'application/xml': { schema: { type: 'string' } } } } } }));
+ const after = doc(operation({ responses: { 200: { content: { 'application/json': { schema: { properties: { id: { enum: ['a', 'b'] } } } } } } } }));
+ const findings = compareContracts(before, after);
+ assert.ok(has(findings, 'response.content'));
+ assert.ok(has(findings, 'schema.required'));
+ assert.ok(has(findings, 'schema.type'));
+ assert.ok(has(findings, 'schema.enum'));
+});
+
+test('unconstrained schemas becoming constrained are request breaks', () => {
+ const before = doc(operation({ requestBody: { content: { 'application/json': { schema: { type: 'object', properties: { legacy: {} }, additionalProperties: false } } } } }));
+ const after = doc(operation({ requestBody: { content: { 'application/json': { schema: { type: 'object', properties: { added: { type: 'string' } }, additionalProperties: false } } } } }));
+ const findings = compareContracts(before, after);
+ assert.ok(has(findings, 'schema.property'));
+ assert.equal(has(findings, 'schema.type'), false);
+ assert.equal(has(findings, 'schema.enum'), false);
+ const constrained = doc(operation({ requestBody: { content: { 'application/json': { schema: { enum: ['a'] } } } } }));
+ const loose = doc(operation({ requestBody: { content: { 'application/json': { schema: {} } } } }));
+ assert.equal(has(compareContracts(constrained, loose), 'schema.enum'), false);
+ assert.ok(has(compareContracts(loose, constrained), 'schema.enum'));
+ const typed = doc(operation({ requestBody: { content: { 'application/json': { schema: { type: 'string' } } } } }));
+ assert.ok(has(compareContracts(loose, typed), 'schema.type'));
+});
+
+test('additionalProperties uses conservative request and response variance', () => {
+ const requestBefore = doc(operation({ requestBody: { content: { 'application/json': { schema: { type: 'object' } } } } }));
+ const requestAfter = doc(operation({ requestBody: { content: { 'application/json': { schema: { type: 'object', additionalProperties: false } } } } }));
+ assert.ok(has(compareContracts(requestBefore, requestAfter), 'schema.additionalProperties'));
+ const responseBefore = doc(operation({ responses: { 200: { content: { 'application/json': { schema: { type: 'object', additionalProperties: false } } } } } }));
+ const responseAfter = doc(operation({ responses: { 200: { content: { 'application/json': { schema: { type: 'object' } } } } } }));
+ assert.ok(has(compareContracts(responseBefore, responseAfter), 'schema.additionalProperties'));
+ const schemaBefore = doc(operation({
+ requestBody: { content: { 'application/json': { schema: { type: 'object', additionalProperties: { type: 'string' } } } } }
+ }));
+ const schemaAfter = doc(operation({
+ requestBody: { content: { 'application/json': { schema: { type: 'object', additionalProperties: { type: 'number' } } } } }
+ }));
+ assert.ok(has(compareContracts(schemaBefore, schemaAfter), 'schema.type'));
+});
+
+test('common assertion tightening is checked in both variance directions', () => {
+ const request = (schema) => doc(operation({ requestBody: { content: { 'application/json': { schema } } } }));
+ assert.ok(has(compareContracts(request({ type: 'string', maxLength: 12 }), request({ type: 'string', maxLength: 4 })), 'schema.maxLength'));
+ assert.ok(has(compareContracts(request({ type: 'string' }), request({ type: 'string', pattern: '^[A-Z]+$' })), 'schema.pattern'));
+ assert.ok(has(compareContracts(request({ type: 'number', minimum: 1, maximum: 10 }), request({ type: 'number', minimum: 2, maximum: 9 })), 'schema.minimum'));
+ assert.ok(has(compareContracts(request({ type: 'array', items: { type: 'string' } }), request({ type: 'array', items: { type: 'number' }, uniqueItems: true })), 'schema.type'));
+ assert.ok(has(compareContracts(request({ nullable: true }), request({ nullable: false, const: 'x' })), 'schema.nullable'));
+ const response = (schema) => doc(operation({ responses: { 200: { content: { 'application/json': { schema } } } } }));
+ assert.ok(has(compareContracts(response({ type: 'string', maxLength: 4, pattern: '^[A-Z]+$' }), response({ type: 'string', maxLength: 12 })), 'schema.maxLength'));
+ assert.ok(has(compareContracts(response({ type: 'array', uniqueItems: true, items: { type: 'string' } }), response({ type: 'array', uniqueItems: false, items: { type: 'string' } })), 'schema.uniqueItems'));
+ assert.ok(compareContracts(request({ oneOf: [{ type: 'string' }] }), request({ oneOf: [{ type: 'number' }] })).some((item) => item.kind === 'schema.inconclusive'));
+});
+
+test('boolean schemas, const changes, hostile properties, Swagger keywords, and unknown assertions fail closed', () => {
+ const request = (schema) => doc(operation({ requestBody: { content: { 'application/json': { schema } } } }));
+ assert.ok(has(compareContracts(request(true), request(false)), 'schema.boolean'));
+ assert.ok(has(compareContracts(request({ type: 'array', items: true }), request({ type: 'array', items: false })), 'schema.boolean'));
+ assert.ok(has(compareContracts(request({ const: 'A' }), request({ const: 'B' })), 'schema.const'));
+ const hostileProperties = Object.create(null); Object.defineProperty(hostileProperties, '__proto__', { value: { type: 'string' }, enumerable: true });
+ assert.ok(has(compareContracts(request({ type: 'object', properties: hostileProperties }), request({ type: 'object', properties: {} })), 'schema.property'));
+ const swaggerBefore = { swagger: '2.0', paths: { '/x': { get: { parameters: [{ name: 'q', in: 'query', type: 'string', maxLength: 12 }], responses: { 200: { schema: { type: 'string' } } } } } } };
+ const swaggerAfter = structuredClone(swaggerBefore); swaggerAfter.paths['/x'].get.parameters[0].maxLength = 4;
+ assert.ok(has(compareContracts(swaggerBefore, swaggerAfter), 'schema.maxLength'));
+ assert.ok(compareContracts(request({ minContains: 1 }), request({ minContains: 2 })).some((item) => item.kind === 'schema.inconclusive'));
+});
+
+test('local refs decode JSON Pointer and resolve all comparison entry points', () => {
+ const base = {
+ openapi: '3.1.0',
+ components: {
+ schemas: { 'A/B': { type: 'string' }, 'T~N': { type: 'number' } },
+ parameters: { query: { name: 'q', in: 'query', schema: { $ref: '#/components/schemas/A~1B' } } },
+ requestBodies: { body: { content: { 'application/json': { schema: { $ref: '#/components/schemas/A~1B' } } } } },
+ responses: { ok: { content: { 'application/json': { schema: { $ref: '#/components/schemas/A~1B' } } } } }
+ },
+ paths: { '/x': { get: { parameters: [{ $ref: '#/components/parameters/query' }], requestBody: { $ref: '#/components/requestBodies/body' }, responses: { 200: { $ref: '#/components/responses/ok' } } } } }
+ };
+ const changed = structuredClone(base);
+ changed.components.schemas['A/B'] = { $ref: '#/components/schemas/T~0N' };
+ const findings = compareContracts(base, changed);
+ assert.ok(has(findings, 'schema.type'));
+ const pathRef = structuredClone(base);
+ pathRef.components.pathItems = { x: pathRef.paths['/x'] };
+ pathRef.paths['/x'] = { $ref: '#/components/pathItems/x' };
+ assert.doesNotThrow(() => compareContracts(pathRef, pathRef));
+ const cycle = structuredClone(base);
+ cycle.components.schemas.loop = { $ref: '#/components/schemas/loop' };
+ cycle.components.parameters.query.schema = { $ref: '#/components/schemas/loop' };
+ assert.throws(() => compareContracts(cycle, base), /循环引用/);
+ const bad = structuredClone(base);
+ bad.components.parameters.query = { $ref: '#/components/parameters/missing' };
+ assert.throws(() => compareContracts(bad, base), /本地 \$ref 无效/);
+});
+
+test('effective global and operation security respects explicit empty arrays', () => {
+ const before = doc(operation(), { security: [{ bearer: [] }] });
+ const anonymous = doc(operation({ security: [] }), { security: [{ bearer: [] }] });
+ const required = doc(operation(), {});
+ assert.ok(compareContracts(before, anonymous).some((item) => item.level === 'info' && item.kind === 'security'));
+ assert.ok(has(compareContracts(anonymous, before), 'security'));
+ assert.ok(has(compareContracts(required, before), 'security'));
+ assert.equal(compareContracts(required, doc(operation({ security: [] }))).some((item) => item.kind === 'security'), false);
+ const reordered = doc(operation(), { security: [{ oauth: ['write', 'read'], bearer: [] }, { api: [] }] });
+ assert.equal(compareContracts(doc(operation(), { security: [{ api: [] }, { bearer: [], oauth: ['read', 'write'] }] }), reordered).some((item) => item.kind === 'security'), false);
+});
+
+test('an empty security requirement object remains a valid anonymous alternative', () => {
+ const anonymousObject = doc(operation({ security: [{}] }));
+ const anonymousMixed = doc(operation({ security: [{ bearer: [] }, {}] }));
+ const anonymousEmpty = doc(operation({ security: [] }));
+ const required = doc(operation({ security: [{ bearer: [] }] }));
+ assert.equal(compareContracts(anonymousObject, anonymousEmpty).some((item) => item.kind === 'security'), false);
+ assert.equal(compareContracts(anonymousMixed, anonymousEmpty).some((item) => item.kind === 'security'), false);
+ assert.ok(has(compareContracts(anonymousObject, required), 'security'));
+ assert.ok(compareContracts(required, anonymousObject).some((item) => item.level === 'info' && item.kind === 'security'));
+});
+
+test('reports non-breaking methods, optional parameters and properties', () => {
+ const before = doc(operation({ requestBody: { content: { 'application/json': { schema: { type: 'object', properties: {} } } } } }));
+ const after = {
+ openapi: '3.1.0',
+ paths: {
+ '/pets/{id}': {
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
+ get: operation({ parameters: [{ name: 'page', in: 'query', schema: { type: 'integer' } }], requestBody: { content: { 'application/json': { schema: { type: 'object', properties: { note: { type: 'string' } } } } } } }),
+ post: operation()
+ }
+ }
+ };
+ const levels = compareContracts(before, after).filter((item) => item.level === 'non-breaking');
+ assert.ok(levels.some((item) => item.reason.includes('新增可选参数')));
+ assert.ok(levels.some((item) => item.reason.includes('新增可选请求属性')));
+ assert.ok(levels.some((item) => item.kind === 'method'));
+});
+
+test('YAML is parsed only in preload and aliases/tags are rejected there', () => {
+ const fixture = fileURLToPath(new URL('./fixtures/openapi.yaml', import.meta.url));
+ preload.__testGrant([fixture]);
+ assert.equal(JSON.parse(preload.readGranted()[0]).paths['/pets'].get.parameters[0].name, 'limit');
+ for (const name of ['alias.yaml', 'tag.yaml']) {
+ preload.__testGrant([fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url))]);
+ assert.throws(() => preload.readGranted(), /不允许 YAML 锚点、别名或显式标签/);
+ }
+ assert.throws(() => parseDocument('openapi: 3.1.0\npaths: {}'));
+});
+
+test('markdown escapes untrusted fields and pointers are escaped', () => {
+ const markdown = reportMarkdown([{ level: 'breaking', kind: 'schema.type', pointer: '/a/~b', reason: '\ntext' }]);
+ assert.match(markdown, /数据类型/);
+ assert.doesNotMatch(markdown, /schema\.type/);
+ assert.match(markdown, /\\ text/);
+ assert.match(markdown, /^# OpenAPI 契约门禁报告/m);
+ assert.match(markdown, /^## 破坏性变更/m);
+ assert.ok(compareContracts({ openapi: '3.0.0', paths: { '/a/b': { get: operation() } } }, { openapi: '3.0.0', paths: {} }).some((item) => item.pointer === '/paths/~1a~1b'));
+ const nested = compareContracts(doc(operation({ parameters: [{ name: 'q', in: 'query', schema: { type: 'string' } }] })), doc(operation({ parameters: [{ name: 'q', in: 'query', schema: { type: 'number' } }] })));
+ assert.ok(nested.some((item) => item.pointer === '/paths/~1pets~1{id}/get/parameters/query:q/schema'));
+});
+
+test('every stable finding kind has a deterministic Chinese human label', () => {
+ const expected = { endpoint: '接口端点', method: '请求方法', parameter: '参数', 'parameter.required': '参数必填性', response: '响应', security: '安全要求', 'requestBody.required': '请求体必填性', 'requestBody.content': '请求体内容类型', 'response.content': '响应内容类型', 'schema.type': '数据类型', 'schema.enum': '枚举范围', 'schema.required': '必填字段', 'schema.property': '对象属性', 'schema.additionalProperties': '附加属性策略', 'schema.boolean': '布尔结构定义', 'schema.inconclusive': '无法确定的结构约束', 'schema.minLength': '最小长度', 'schema.minimum': '最小值', 'schema.exclusiveMinimum': '排他最小值', 'schema.minItems': '最少元素数', 'schema.minProperties': '最少属性数', 'schema.maxLength': '最大长度', 'schema.maximum': '最大值', 'schema.exclusiveMaximum': '排他最大值', 'schema.maxItems': '最多元素数', 'schema.maxProperties': '最多属性数', 'schema.nullable': '可空性', 'schema.const': '常量值', 'schema.pattern': '正则模式', 'schema.format': '格式约束', 'schema.multipleOf': '倍数约束', 'schema.uniqueItems': '元素唯一性', 'schema.items': '数组元素' };
+ for (const [kind, label] of Object.entries(expected)) assert.equal(humanFindingKind(kind), label);
+ assert.equal(humanFindingKind('schema.future'), '未分类变更');
+});
+
+test('preload clears canceled, expired and failed multi-file selections', async () => {
+ const fixture = fileURLToPath(new URL('./fixtures/openapi.yaml', import.meta.url));
+ preload.__testGrant([fixture]);
+ const preserved = preload.__testGrants()[0];
+ preload.readGranted();
+ assert.equal(preload.__testGrants().length, 1);
+ assert.doesNotThrow(() => fs.fstatSync(preserved.fd));
+ let replacedGrantClosed = false;
+ preload.__testSetCloseSync(function observedClose(fd) {
+ if (fd === preserved.fd) replacedGrantClosed = true;
+ return fs.closeSync(fd);
+ });
+ try { preload.__testGrant([fixture]); }
+ finally { preload.__testResetCloseSync(); }
+ assert.equal(replacedGrantClosed, true);
+ await preload.bridge({ showOpenDialog: async () => ({ filePaths: [] }) }).choose();
+ assert.equal(preload.__testGrants().length, 0);
+ assert.throws(() => preload.__testGrant([fixture, '/does-not-exist.yaml']));
+ assert.equal(preload.__testGrants().length, 0);
+ preload.__testGrant([fixture]);
+ preload.__testGrants()[0].until = 0;
+ assert.throws(() => preload.readGranted(), /授权已过期/);
+ assert.equal(preload.__testGrants().length, 0);
+ preload.__testGrant([fixture], 5);
+ const automaticallyExpired = preload.__testGrants()[0];
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ assert.equal(preload.__testGrants().length, 0);
+ assert.throws(() => fs.fstatSync(automaticallyExpired.fd), { code: 'EBADF' });
+});
+
+test('failed descriptor close revokes file metadata and the next authorization retries it immediately', () => {
+ const fixture = fileURLToPath(new URL('./fixtures/openapi.yaml', import.meta.url));
+ preload.__testClear();
+ preload.__testGrant([fixture]);
+ const selected = preload.__testGrants()[0];
+ let failOnce = true;
+ let retried = false;
+ preload.__testSetCloseSync((fd) => {
+ if (fd === selected.fd && failOnce) {
+ failOnce = false;
+ const error = new Error('simulated interrupted close');
+ error.code = 'EINTR';
+ throw error;
+ }
+ if (fd === selected.fd) retried = true;
+ return fs.closeSync(fd);
+ });
+ try {
+ preload.__testClear();
+ assert.equal(preload.__testGrants().length, 0);
+ assert.equal(preload.__testPendingCloses().length, 1);
+ assert.deepEqual(Object.keys(preload.__testPendingCloses()[0]).sort(), ['closeFailed', 'closed', 'fd']);
+ assert.equal(Object.hasOwn(preload.__testPendingCloses()[0], 'real'), false);
+ assert.doesNotThrow(() => fs.fstatSync(selected.fd));
+
+ preload.__testGrant([fixture]);
+ assert.equal(preload.__testPendingCloses().length, 0);
+ assert.equal(retried, true);
+ } finally {
+ preload.__testResetCloseSync();
+ preload.__testRetryPendingCloses();
+ preload.__testClear();
+ }
+});
+
+test('grant replacement queues only a descriptor and plugin exit retries it', () => {
+ const fixture = fileURLToPath(new URL('./fixtures/openapi.yaml', import.meta.url));
+ preload.__testClear();
+ preload.__testGrant([fixture]);
+ const replaced = preload.__testGrants()[0];
+ let failOnce = true;
+ preload.__testSetCloseSync((fd) => {
+ if (fd === replaced.fd && failOnce) {
+ failOnce = false;
+ const error = new Error('simulated close failure');
+ error.code = 'EIO';
+ throw error;
+ }
+ return fs.closeSync(fd);
+ });
+ try {
+ preload.__testGrant([fixture]);
+ const replacement = preload.__testGrants()[0];
+ assert.equal(preload.__testPendingCloses().length, 1);
+ assert.deepEqual(Object.keys(preload.__testPendingCloses()[0]).sort(), ['closeFailed', 'closed', 'fd']);
+ assert.equal(JSON.stringify(preload.__testPendingCloses()).includes(fixture), false);
+
+ let onPluginOut;
+ preload.bridge({ onPluginOut(listener) { onPluginOut = listener; } });
+ onPluginOut();
+ assert.equal(preload.__testPendingCloses().length, 0);
+ assert.equal(preload.__testGrants().length, 0);
+ assert.throws(() => fs.fstatSync(replaced.fd), { code: 'EBADF' });
+ assert.throws(() => fs.fstatSync(replacement.fd), { code: 'EBADF' });
+ } finally {
+ preload.__testResetCloseSync();
+ preload.__testRetryPendingCloses();
+ preload.__testClear();
+ }
+});
+
+test('TTL cleanup uses the fd-only timer fallback and EBADF is already closed', async () => {
+ const fixture = fileURLToPath(new URL('./fixtures/openapi.yaml', import.meta.url));
+ preload.__testClear();
+ preload.__testGrant([fixture], 5);
+ const expiring = preload.__testGrants()[0];
+ let failOnce = true;
+ preload.__testSetCloseSync((fd) => {
+ if (fd === expiring.fd && failOnce) {
+ failOnce = false;
+ const error = new Error('simulated close failure');
+ error.code = 'EIO';
+ throw error;
+ }
+ return fs.closeSync(fd);
+ });
+ try {
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ assert.equal(preload.__testGrants().length, 0);
+ assert.equal(preload.__testPendingCloses().length, 1);
+ await new Promise((resolve) => setTimeout(resolve, 125));
+ assert.equal(preload.__testPendingCloses().length, 0);
+ assert.throws(() => fs.fstatSync(expiring.fd), { code: 'EBADF' });
+
+ preload.__testGrant([fixture]);
+ const externallyClosed = preload.__testGrants()[0];
+ fs.closeSync(externallyClosed.fd);
+ preload.__testClear();
+ assert.equal(preload.__testPendingCloses().length, 0);
+ } finally {
+ preload.__testResetCloseSync();
+ preload.__testRetryPendingCloses();
+ preload.__testClear();
+ }
+});
+
+test('plugin exit clears preserved UI grants without exposing paths or tokens', async () => {
+ const fixture = fileURLToPath(new URL('./fixtures/openapi.yaml', import.meta.url));
+ let onPluginOut;
+ const renderer = preload.bridge({
+ onPluginOut(callback) { onPluginOut = callback; },
+ showOpenDialog: async () => ({ filePaths: [fixture, fixture] })
+ });
+ const names = await renderer.choose();
+ assert.deepEqual(names, ['openapi.yaml', 'openapi.yaml']);
+ assert.equal(JSON.stringify(names).includes(path.dirname(fixture)), false);
+ assert.deepEqual(Object.keys(renderer).sort(), ['choose', 'copyText', 'readGranted']);
+ renderer.readGranted();
+ const selected = [...preload.__testGrants()];
+ assert.equal(selected.length, 2);
+ onPluginOut();
+ assert.equal(preload.__testGrants().length, 0);
+ for (const item of selected) assert.throws(() => fs.fstatSync(item.fd), { code: 'EBADF' });
+});
+
+test('plugin exit invalidates a pending contract chooser before it can restore file grants', async () => {
+ const fixture = fileURLToPath(new URL('./fixtures/openapi.yaml', import.meta.url));
+ let onPluginOut;
+ let resolveDialog;
+ const dialog = new Promise((resolve) => { resolveDialog = resolve; });
+ const renderer = preload.bridge({
+ onPluginOut(callback) { onPluginOut = callback; },
+ showOpenDialog: async () => dialog
+ });
+ const pending = renderer.choose();
+ onPluginOut();
+ resolveDialog({ filePaths: [fixture, fixture] });
+ await assert.rejects(pending, (error) => error?.code === 'SESSION_EXPIRED');
+ assert.equal(preload.__testGrants().length, 0);
+});
+
+test('grant identity rejects same-inode same-size rewrites with restored mtime', () => {
+ const directory = fs.mkdtempSync(path.join(process.cwd(), 'test', 'openapi-identity-'));
+ const file = path.join(directory, 'contract.json');
+ const before = JSON.stringify({ openapi: '3.1.0', paths: {}, info: { title: 'A' } });
+ const after = JSON.stringify({ openapi: '3.1.0', paths: {}, info: { title: 'B' } });
+ assert.equal(Buffer.byteLength(before), Buffer.byteLength(after));
+ fs.writeFileSync(file, before);
+ const fixedTime = new Date(1700000000000);
+ fs.utimesSync(file, fixedTime, fixedTime);
+ const original = fs.statSync(file);
+ preload.__testGrant([file]);
+ const identity = preload.__testGrants()[0];
+ assert.equal(typeof identity.ctime, 'number');
+ assert.match(identity.digest, /^[a-f0-9]{64}$/);
+ fs.writeFileSync(file, after);
+ fs.utimesSync(file, original.atime, original.mtime);
+ const changed = fs.statSync(file);
+ assert.equal(changed.ino, original.ino);
+ assert.equal(changed.size, original.size);
+ assert.equal(changed.mtimeMs, original.mtimeMs);
+ assert.throws(() => preload.readGranted(), /发生变化|不一致/);
+ assert.equal(preload.__testGrants().length, 0);
+ fs.rmSync(directory, { recursive: true, force: true });
+});
+
+test('grant identity fails closed when a file is rewritten during the same-handle read', () => {
+ const directory = fs.mkdtempSync(path.join(process.cwd(), 'test', 'openapi-mid-read-'));
+ const file = path.join(directory, 'contract.json');
+ const before = JSON.stringify({ openapi: '3.1.0', paths: {}, info: { title: 'A' } });
+ const after = JSON.stringify({ openapi: '3.1.0', paths: {}, info: { title: 'B' } });
+ fs.writeFileSync(file, before);
+ const fixedTime = new Date(1700000000000);
+ fs.utimesSync(file, fixedTime, fixedTime);
+ const original = fs.statSync(file);
+ preload.__testGrant([file]);
+ const selectedFd = preload.__testGrants()[0].fd;
+ const originalRead = fs.readSync;
+ let rewritten = false;
+ fs.readSync = function patchedRead(fd, ...args) {
+ if (fd === selectedFd && !rewritten) {
+ rewritten = true;
+ fs.writeFileSync(file, after);
+ fs.utimesSync(file, original.atime, original.mtime);
+ }
+ return originalRead.call(this, fd, ...args);
+ };
+ try {
+ assert.throws(() => preload.readGranted(), /读取期间发生变化|不一致/);
+ assert.equal(rewritten, true);
+ assert.equal(preload.__testGrants().length, 0);
+ } finally {
+ fs.readSync = originalRead;
+ preload.__testClear();
+ fs.rmSync(directory, { recursive: true, force: true });
+ }
+});
+
+test('document parsing accepts only OpenAPI 3.x or Swagger 2.0 with plain-object paths', async () => {
+ assert.doesNotThrow(() => parseDocument(JSON.stringify({ openapi: '3.0.0', paths: {} })));
+ assert.doesNotThrow(() => parseDocument(JSON.stringify({ openapi: '3.1.1-beta.1', paths: {} })));
+ assert.doesNotThrow(() => parseDocument(JSON.stringify({ openapi: '3.1.1-beta.1+build.7', paths: {} })));
+ assert.doesNotThrow(() => parseDocument(JSON.stringify({ swagger: '2.0', paths: {} })));
+ for (const invalid of [
+ { openapi: '2.0.0', paths: {} },
+ { openapi: '4.0.0', paths: {} },
+ { openapi: 3.1, paths: {} },
+ { swagger: '2.1', paths: {} },
+ { swagger: 2, paths: {} },
+ { openapi: '3.1.0', paths: [] },
+ { openapi: '3.1.0', paths: null }
+ ]) assert.throws(() => parseDocument(JSON.stringify(invalid)), /OpenAPI 3\.x|paths 必须是普通对象/);
+ const valid = JSON.stringify({ openapi: '3.1.0', paths: {} });
+ await assert.rejects(preload.compareInline({ before: JSON.stringify({ openapi: '4.0.0', paths: {} }), after: valid, format: 'json' }), (error) => error.code === 'CONTRACT_COMPARISON_FAILED');
+});
+
+test('node auditing counts flat object fields and array entries', () => {
+ const fields = Object.fromEntries(Array.from({ length: 40001 }, (_, index) => [`f${index}`, index]));
+ assert.throws(() => parseDocument(JSON.stringify({ openapi: '3.1.0', paths: {}, components: fields })), /节点数量超过限制/);
+ assert.throws(() => parseDocument(JSON.stringify({ openapi: '3.1.0', paths: {}, values: Array(40001).fill(0) })), /节点数量超过限制/);
+});
+
+test('large comparisons use a bounded page collector and a hard finding ceiling', () => {
+ const paths = Object.fromEntries(Array.from({ length: 350 }, (_, index) => [`/route-${String(index).padStart(3, '0')}`, { get: operation() }]));
+ const collector = createFindingPageCollector(100, 100);
+ assert.equal(compareContracts({ openapi: '3.1.0', paths }, { openapi: '3.1.0', paths: {} }, collector), collector);
+ assert.equal(collector.counts.breaking, 350);
+ assert.equal(collector.counts.total, 350);
+ assert.equal(collector.findings.length, 100);
+ assert.equal(collector.findings[0].pointer, '/paths/~1route-100');
+ const excessivePaths = Object.fromEntries(Array.from({ length: 10001 }, (_, index) => [`/x-${index}`, { get: operation() }]));
+ assert.throws(() => compareContracts({ openapi: '3.1.0', paths: excessivePaths }, { openapi: '3.1.0', paths: {} }), /差异数量超过 10000 条限制/);
+});
+
+test('renderer uses DOM text and path contract is cross-platform', () => {
+ const renderer = fs.readFileSync(new URL('../src/main/app.js', import.meta.url), 'utf8');
+ assert.equal(renderer.includes('innerHTML'), false);
+ assert.match(renderer, /const UI_PAGE_SIZE = 100/);
+ assert.match(renderer, /createFindingPageCollector\(offset, UI_PAGE_SIZE\)/);
+ assert.match(renderer, /humanFindingKind\(finding\.kind\)/);
+ assert.match(renderer, /function showError\(error\)[\s\S]*?currentPage = null;[\s\S]*?setResultControls\(false\)/);
+ assert.match(renderer, /if \(currentPage\) window\.contractGate\?\.copyText/);
+ const html = fs.readFileSync(new URL('../src/main/index.html', import.meta.url), 'utf8');
+ assert.match(html, /id="copy-md" disabled/);
+ assert.match(html, /id="previous-page" disabled/);
+ assert.match(html, /id="next-page" disabled/);
+ const style = fs.readFileSync(new URL('../src/main/style.css', import.meta.url), 'utf8');
+ assert.match(style, /\.entry code\{[^}]*overflow-wrap:anywhere/);
+ assert.match(style, /textarea\{[^}]*min-width:0/);
+ assert.doesNotMatch(fs.readFileSync(new URL('../src/core/contract.js', import.meta.url), 'utf8'), /^\s*import\s/m);
+ for (const platform of ['win32', 'darwin', 'linux']) assert.ok(pathContract(platform, platform === 'win32' ? 'C:\\x\\a.yaml' : '/x/a.yaml').accepted);
+});
diff --git a/plugins/openapi-contract-gate/test/dist-size.test.mjs b/plugins/openapi-contract-gate/test/dist-size.test.mjs
new file mode 100644
index 00000000..33998e91
--- /dev/null
+++ b/plugins/openapi-contract-gate/test/dist-size.test.mjs
@@ -0,0 +1,59 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import path from 'node:path';
+import {
+ DIST_SIZE_LIMIT_BYTES,
+ assertWithinDistSizeLimit,
+ directoryBytes
+} from '../scripts/dist-size.mjs';
+
+function metadata(kind, size = 0) {
+ return {
+ size,
+ isDirectory: () => kind === 'directory',
+ isFile: () => kind === 'file',
+ isSymbolicLink: () => kind === 'symlink'
+ };
+}
+
+function virtualDirectory(root, tree, entries) {
+ return directoryBytes(root, {
+ readEntries: async (directory) => (tree.get(directory) || []).map((name) => ({ name })),
+ inspectEntry: async (entryPath) => entries.get(entryPath),
+ baseDirectory: root
+ });
+}
+
+test('size gate allows exactly 14,500,000 bytes', () => {
+ assert.equal(assertWithinDistSizeLimit(DIST_SIZE_LIMIT_BYTES), 14_500_000);
+});
+
+test('size gate rejects 14,500,001 bytes without creating a large file', () => {
+ assert.throws(() => assertWithinDistSizeLimit(DIST_SIZE_LIMIT_BYTES + 1), /14\.5 MB safety limit/);
+});
+
+test('directory byte count includes nested regular files recursively', async () => {
+ const root = path.resolve('/virtual/openapi-dist');
+ const nested = path.join(root, 'nested');
+ const tree = new Map([[root, ['root.js', 'nested']], [nested, ['child.css']]]);
+ const entries = new Map([
+ [path.join(root, 'root.js'), metadata('file', 17)],
+ [nested, metadata('directory')],
+ [path.join(nested, 'child.css'), metadata('file', 23)]
+ ]);
+ assert.equal(await virtualDirectory(root, tree, entries), 40);
+});
+
+test('directory byte count rejects symbolic links and other special files', async () => {
+ for (const [name, kind, pattern] of [
+ ['linked.js', 'symlink', /symbolic link/],
+ ['socket', 'special', /special file/]
+ ]) {
+ const root = path.resolve(`/virtual/openapi-${kind}`);
+ const entryPath = path.join(root, name);
+ await assert.rejects(
+ virtualDirectory(root, new Map([[root, [name]]]), new Map([[entryPath, metadata(kind)]])),
+ pattern
+ );
+ }
+});
diff --git a/plugins/openapi-contract-gate/test/fixtures/alias.yaml b/plugins/openapi-contract-gate/test/fixtures/alias.yaml
new file mode 100644
index 00000000..2d64e70a
--- /dev/null
+++ b/plugins/openapi-contract-gate/test/fixtures/alias.yaml
@@ -0,0 +1,2 @@
+openapi: 3.1.0
+paths: &paths {}
diff --git a/plugins/openapi-contract-gate/test/fixtures/openapi.yaml b/plugins/openapi-contract-gate/test/fixtures/openapi.yaml
new file mode 100644
index 00000000..c6904d66
--- /dev/null
+++ b/plugins/openapi-contract-gate/test/fixtures/openapi.yaml
@@ -0,0 +1,22 @@
+openapi: 3.0.3
+servers:
+ - url: https://example.test
+paths:
+ /pets:
+ get:
+ parameters:
+ - name: limit
+ in: query
+ required: true
+ responses:
+ '200':
+ description: ok
+components:
+ schemas:
+ Pet:
+ type: object
+ required:
+ - id
+ properties:
+ id:
+ type: string
diff --git a/plugins/openapi-contract-gate/test/fixtures/tag.yaml b/plugins/openapi-contract-gate/test/fixtures/tag.yaml
new file mode 100644
index 00000000..661c5bc7
--- /dev/null
+++ b/plugins/openapi-contract-gate/test/fixtures/tag.yaml
@@ -0,0 +1,2 @@
+openapi: !!str 3.1.0
+paths: ! {}
diff --git a/plugins/openapi-contract-gate/test/host-contract.test.mjs b/plugins/openapi-contract-gate/test/host-contract.test.mjs
new file mode 100644
index 00000000..33b3f166
--- /dev/null
+++ b/plugins/openapi-contract-gate/test/host-contract.test.mjs
@@ -0,0 +1,41 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+function resolveLocalEntry(relative) {
+ assert.equal(path.isAbsolute(relative), false);
+ const resolved = path.resolve(root, relative);
+ assert.ok(resolved.startsWith(`${root}${path.sep}`));
+ assert.ok(fs.statSync(resolved).isFile(), `${relative} must be a loadable file`);
+ return resolved;
+}
+
+test('host manifest directly loads source entries while dist remains a self-contained CI artifact', () => {
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'plugin.json'), 'utf8'));
+ assert.equal(Object.hasOwn(manifest, 'development'), false);
+ assert.deepEqual(
+ { main: manifest.main, preload: manifest.preload, logo: manifest.logo },
+ { main: 'src/main/index.html', preload: 'src/preload/index.cjs', logo: 'logo.svg' }
+ );
+
+ const main = resolveLocalEntry(manifest.main);
+ resolveLocalEntry(manifest.preload);
+ resolveLocalEntry(manifest.logo);
+ const html = fs.readFileSync(main, 'utf8');
+ assert.match(html, //);
+ assert.match(html, /逐项核对兼容性/);
+ assert.doesNotMatch(html, /Previous contract|Candidate contract|Balance the ledger/);
+ for (const asset of ['./style.css', './app.js']) {
+ assert.match(html, new RegExp(`(?:href|src)=["']${asset.replace('.', '\\.')}`));
+ assert.ok(fs.statSync(path.resolve(path.dirname(main), asset)).isFile());
+ }
+
+ const build = fs.readFileSync(path.join(root, 'scripts', 'build.mjs'), 'utf8');
+ assert.match(build, /delete m\.development/);
+ assert.match(build, /m\.main='main\/index\.html'/);
+ assert.match(build, /m\.preload='preload\/index\.cjs'/);
+});