From ba833dda10ddedd334af6f0db4049a4c61f2f469 Mon Sep 17 00:00:00 2001 From: wangzihao Date: Mon, 31 Aug 2026 17:04:13 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(openapi-contract-gate):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20OpenAPI=20=E5=A5=91=E7=BA=A6=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI-Co-Authored-By: Codex --- plugins/openapi-contract-gate/.gitignore | 2 + plugins/openapi-contract-gate/CHANGELOG.md | 5 + plugins/openapi-contract-gate/README.md | 7 + plugins/openapi-contract-gate/logo.svg | 1 + .../openapi-contract-gate/package-lock.json | 30 ++ plugins/openapi-contract-gate/package.json | 1 + plugins/openapi-contract-gate/plugin.json | 1 + .../openapi-contract-gate/scripts/build.mjs | 1 + .../scripts/verify-dist.mjs | 1 + .../src/core/contract.js | 262 ++++++++++++++++++ plugins/openapi-contract-gate/src/main/app.js | 1 + .../openapi-contract-gate/src/main/index.html | 1 + .../openapi-contract-gate/src/main/style.css | 1 + .../src/preload/index.cjs | 87 ++++++ .../test/contract.test.mjs | 213 ++++++++++++++ .../test/fixtures/alias.yaml | 2 + .../test/fixtures/openapi.yaml | 22 ++ .../test/fixtures/tag.yaml | 2 + 18 files changed, 640 insertions(+) create mode 100644 plugins/openapi-contract-gate/.gitignore create mode 100644 plugins/openapi-contract-gate/CHANGELOG.md create mode 100644 plugins/openapi-contract-gate/README.md create mode 100644 plugins/openapi-contract-gate/logo.svg create mode 100644 plugins/openapi-contract-gate/package-lock.json create mode 100644 plugins/openapi-contract-gate/package.json create mode 100644 plugins/openapi-contract-gate/plugin.json create mode 100644 plugins/openapi-contract-gate/scripts/build.mjs create mode 100644 plugins/openapi-contract-gate/scripts/verify-dist.mjs create mode 100644 plugins/openapi-contract-gate/src/core/contract.js create mode 100644 plugins/openapi-contract-gate/src/main/app.js create mode 100644 plugins/openapi-contract-gate/src/main/index.html create mode 100644 plugins/openapi-contract-gate/src/main/style.css create mode 100644 plugins/openapi-contract-gate/src/preload/index.cjs create mode 100644 plugins/openapi-contract-gate/test/contract.test.mjs create mode 100644 plugins/openapi-contract-gate/test/fixtures/alias.yaml create mode 100644 plugins/openapi-contract-gate/test/fixtures/openapi.yaml create mode 100644 plugins/openapi-contract-gate/test/fixtures/tag.yaml 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..1926c442 --- /dev/null +++ b/plugins/openapi-contract-gate/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial local OpenAPI/Swagger comparison gate. diff --git a/plugins/openapi-contract-gate/README.md b/plugins/openapi-contract-gate/README.md new file mode 100644 index 00000000..0845771a --- /dev/null +++ b/plugins/openapi-contract-gate/README.md @@ -0,0 +1,7 @@ +# OpenAPI Contract Gate + +An offline contract ledger for OpenAPI 3 and Swagger 2 JSON or conservative YAML. It compares endpoints, methods, parameters, request bodies, responses, security, schema required fields, types, and enums, with JSON Pointer evidence for each finding. + +YAML is parsed only in the preload boundary. Normal mappings, sequences, quoted values, and block scalars are accepted; anchors, aliases, explicit tags, duplicate keys, and remote `$ref` values are rejected instead of being resolved. Files are capped at 10 MiB, depth 60, and 40,000 audited nodes. + +Node contract tests, packaged-dependency checks, source/dist identity, and Chromium rendering are verified. Loading and file-dialog behavior in real Windows, macOS, and Linux ZTools hosts remain untested. 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..88af8260 --- /dev/null +++ b/plugins/openapi-contract-gate/package.json @@ -0,0 +1 @@ +{"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..049e89f9 --- /dev/null +++ b/plugins/openapi-contract-gate/plugin.json @@ -0,0 +1 @@ +{"name":"openapi-contract-gate","title":"OpenAPI 契约门禁","version":"0.1.0","description":"Offline OpenAPI compatibility ledger and breaking-change gate.","author":"harris","platform":["darwin","win32","linux"],"categories":["development"],"main":"dist/main/index.html","preload":"dist/preload/index.cjs","logo":"dist/logo.svg","development":{"main":"src/main/index.html","preload":"src/preload/index.cjs"},"features":[{"code":"compare-openapi","icon":"logo.svg","platform":["darwin","win32","linux"],"explain":"比较一到两个 OpenAPI 契约","cmds":["OpenAPI 对比","API 契约门禁"]}]} 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/verify-dist.mjs b/plugins/openapi-contract-gate/scripts/verify-dist.mjs new file mode 100644 index 00000000..ee1b498c --- /dev/null +++ b/plugins/openapi-contract-gate/scripts/verify-dist.mjs @@ -0,0 +1 @@ +import{access,readFile}from'node:fs/promises';import path from'node:path';import{fileURLToPath}from'node:url';const r=path.dirname(path.dirname(fileURLToPath(import.meta.url))),d=path.join(r,'dist');for(const f of['plugin.json','main/index.html','preload/index.cjs','core/contract.js','logo.svg','preload/node_modules/yaml/package.json'])await access(path.join(d,f));if(JSON.parse(await readFile(path.join(d,'plugin.json'))).development)throw Error('development leaked');console.log('openapi-contract-gate dist verified'); 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..7a5daae7 --- /dev/null +++ b/plugins/openapi-contract-gate/src/core/contract.js @@ -0,0 +1,262 @@ +const MAX_BYTES = 10 * 1024 * 1024; +const MAX_DEPTH = 60; +const MAX_NODES = 40000; +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 (value === null || typeof value !== 'object' || seen.has(value)) continue; + seen.add(value); + if (depth > MAX_DEPTH) throw Error('Document nesting exceeds limit'); + if (++nodes > MAX_NODES) throw Error('Document node count exceeds limit'); + for (const [key, next] of Object.entries(value)) { + if (key === '$ref' && typeof next === 'string' && !next.startsWith('#/')) throw Error('Remote $ref is not allowed'); + stack.push([next, depth + 1]); + } + } +} + +export function parseDocument(text) { + const source = String(text); + if (utf8Length(source) > MAX_BYTES) throw Error('Document exceeds 10 MiB limit'); + let document; + try { document = JSON.parse(source); } catch (error) { throw Error(`Invalid contract: ${error.message}`); } + if (!document || typeof document !== 'object' || !(document.openapi || document.swagger)) throw Error('Requires OpenAPI 3 or Swagger 2 root'); + auditDocument(document); + return document; +} + +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('Local $ref cycle exceeds limit'); + if (typeof ref !== 'string' || !ref.startsWith('#/')) throw Error('Only local $ref values are allowed'); + 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(`Invalid local $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 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'), `${direction} additional properties compatibility narrowed`)); + } + 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), `${direction} assertion compatibility changed`)); } +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), `${direction} ${name} changed and compatibility cannot be proven`)); + } + 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), `${direction} ${name} changed and compatibility cannot be proven`)); + } +} + +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, `${direction} boolean schema compatibility changed`)); + 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, `${direction} type compatibility narrowed`)); + } else if (oldTypes && newTypes) { + const invalid = direction === 'request' ? missing(oldTypes, newTypes) : missing(newTypes, oldTypes); + if (invalid.length) out.push(finding('breaking', 'schema.type', where, `${direction} type compatibility changed: ${invalid.join(', ')}`)); + } + if ((direction === 'request' && !oldValue.enum && newValue.enum) || (direction === 'response' && oldValue.enum && !newValue.enum)) { + out.push(finding('breaking', 'schema.enum', where, `${direction} enum compatibility narrowed`)); + } 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, `${direction} enum compatibility changed: ${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'), `Field ${name} became required`)); + if (direction === 'response') for (const name of missing(oldRequired, newRequired)) out.push(finding('breaking', 'schema.required', pointer(where, 'required'), `Response field ${name} is no longer required`)); + 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), 'Response property removed')); + if (direction === 'request') out.push(finding('breaking', 'schema.property', pointer(where, 'properties', name), 'Accepted request property removed')); + 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), 'Optional request property added')); +} + +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 []; + 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, 'Operation allows anonymous access')); + else if (oldSecurity.length === 0) out.push(finding('breaking', 'security', where, 'Operation now requires security')); + else out.push(finding('breaking', 'security', where, 'Effective security requirements changed')); +} +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, 'New required request body')); return; } + if (oldBody && !newBody) { out.push(finding('breaking', 'requestBody.content', where, 'Request body support removed')); return; } + if (!oldBody || !newBody) return; + if (!oldBody.required && newBody.required) out.push(finding('breaking', 'requestBody.required', where, 'Request body became required')); + for (const [type, media] of Object.entries(oldBody.content || {})) { + if (!newBody.content?.[type]) out.push(finding('breaking', 'requestBody.content', pointer(where, 'content', type), `Accepted content type ${type} removed`)); + 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), `Response content type ${type} removed`)); + else compareSchema(media.schema, newContent[type].schema, pointer(where, 'content', type, 'schema'), out, oldDoc, newDoc, 'response'); + } +} + +export function compareContracts(oldDoc, newDoc) { + const out = [], 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), 'Endpoint removed')); 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, 'Method removed')); 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, `Parameter ${key} removed`)); + else { + if (!parameter.required && next.required) out.push(finding('breaking', 'parameter.required', where, `Parameter ${key} became required`)); + 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 ? `New required parameter ${key}` : `Optional parameter ${key} added`)); + } + compareSecurity(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), `Response ${status} removed`)); + 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 ${method.toUpperCase()} added`)); + } + } + } + for (const [route, pathItem] of Object.entries(newPaths)) if (!oldPaths[route]) out.push(finding('non-breaking', 'endpoint', pointer('paths', route), `Endpoint added (${Object.keys(pathItem).filter((key) => METHODS.includes(key)).join(', ')})`)); + return out; +} + +function escapeMarkdown(value) { return String(value).replace(/[\\`*_{}\[\]<>]/g, '\\$&').replace(/\r?\n/g, ' '); } +export function reportMarkdown(findings) { + const groups = ['breaking', 'non-breaking', 'info']; + return ['# OpenAPI Contract Gate', '', ...groups.flatMap((group) => { + const items = findings.filter((item) => item.level === group).map((item) => `- **${escapeMarkdown(item.kind)}** at \`${escapeMarkdown(item.pointer)}\`: ${escapeMarkdown(item.reason)}`); + return [`## ${group}`, ...(items.length ? items : ['- None'])]; + })].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..60996ede --- /dev/null +++ b/plugins/openapi-contract-gate/src/main/app.js @@ -0,0 +1 @@ +import{parseDocument,compareContracts,reportMarkdown}from'../core/contract.js';const ledger=document.querySelector('#ledger');const $=s=>document.querySelector(s);let findings=[];function entry(f){const a=document.createElement('article'),b=document.createElement('b'),br1=document.createElement('br'),code=document.createElement('code'),br2=document.createElement('br');a.className=`entry ${f.level}`;b.textContent=`${f.level} · ${f.kind}`;code.textContent=f.pointer;a.append(b,br1,code,br2,document.createTextNode(f.reason));return a;}function render(){ledger.replaceChildren(...(findings.length?findings.map(entry):[Object.assign(document.createElement('article'),{className:'entry non-breaking',textContent:'No behavioral difference found.'})]));}function run(oldText,nextText){findings=compareContracts(parseDocument(oldText),parseDocument(nextText));render();}function error(e){ledger.replaceChildren(Object.assign(document.createElement('article'),{className:'entry',textContent:e.message}));}$('#compare').onclick=()=>{try{run($('#old').value,$('#next').value);}catch(e){error(e);}};$('#copy-md').onclick=()=>window.contractGate?.copyText?.(reportMarkdown(findings));$('#copy-json').onclick=()=>window.contractGate?.copyText?.(JSON.stringify(findings,null,2));$('#choose').onclick=async()=>{try{await window.contractGate?.choose?.();const docs=window.contractGate?.readGranted?.();if(!docs)throw Error('ZTools bridge unavailable');if(docs[0])$('#old').value=docs[0];if(docs[1])$('#next').value=docs[1];if(docs.length===2)run(docs[0],docs[1]);}catch(e){error(e);}}; 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..eeec2020 --- /dev/null +++ b/plugins/openapi-contract-gate/src/main/index.html @@ -0,0 +1 @@ +OpenAPI Contract Gate
CONTRACT LEDGER

Compatibility, itemized.

API
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..47c33e51 --- /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}.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}button{border:1px solid #1d623d;background:#1d623d;color:white;padding:12px 17px;font-weight:bold;cursor:pointer}#ledger{min-width:0;margin-top:30px;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..daf797e7 --- /dev/null +++ b/plugins/openapi-contract-gate/src/preload/index.cjs @@ -0,0 +1,87 @@ +const fs = require('fs'); +const path = require('path'); +const YAML = require('yaml'); +const MAX = 10 * 1024 * 1024, TTL = 300000, DEPTH = 60, NODES = 40000; +let grants = []; + +function close(record) { try { fs.closeSync(record.fd); } catch {} } +function clear() { for (const record of grants) close(record); grants = []; } +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('Contract exceeds safe structure limits'); + if (value === null || typeof value !== 'object') continue; + if (seen.has(value)) continue; + seen.add(value); + for (const [key, next] of Object.entries(value)) { + queue.push([key, depth + 1]); + if (key === '$ref' && typeof next === 'string' && !next.startsWith('#/')) throw Error('Remote $ref is not allowed'); + queue.push([next, depth + 1]); + } + } +} +function record(file) { + 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('Rejected contract file'); + return { fd, real, size: stat.size, mtime: stat.mtimeMs, dev: stat.dev, ino: stat.ino, until: Date.now() + TTL }; + } catch (error) { close({ fd }); throw error; } +} +function grant(files) { + clear(); + if (!Array.isArray(files) || files.length < 1 || files.length > 2) throw Error('Choose one or two contract files'); + const selected = []; + try { for (const file of files) selected.push(record(file)); grants = selected; return grants.map((item) => path.basename(item.real)); } + catch (error) { for (const item of selected) close(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 anchors, aliases and explicit tags are not allowed'); + 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 anchors, aliases and explicit tags are not allowed'); + throw Error(message); + } + auditYamlNode(document.contents); + return document.toJS({ maxAliasCount: 0 }); +} +function read(record) { + if (Date.now() > record.until) { clear(); throw Error('Selection expired'); } + const stat = fs.fstatSync(record.fd); + if (stat.size !== record.size || stat.mtimeMs !== record.mtime || stat.dev !== record.dev || stat.ino !== record.ino) throw Error('Contract changed after selection'); + const buffer = Buffer.alloc(stat.size); let offset = 0; + while (offset < buffer.length) { const count = fs.readSync(record.fd, buffer, offset, buffer.length - offset, offset); if (!count) throw Error('Incomplete read'); offset += count; } + const document = /\.json$/i.test(record.real) ? JSON.parse(buffer.toString('utf8')) : parseYaml(buffer.toString('utf8')); + audit(document); + const serialized = JSON.stringify(document); + if (Buffer.byteLength(serialized) > MAX) throw Error('Contract exceeds safe serialized size limit'); + return serialized; +} +function readGranted() { + if (!grants.length) throw Error('Choose contract files first'); + try { return grants.map(read); } + finally { clear(); } +} +async function choose(ztools) { + if (typeof ztools?.showOpenDialog !== 'function') throw Error('ZTools file dialog unavailable'); + const result = await ztools.showOpenDialog({ properties: ['openFile', 'multiSelections'], filters: [{ name: 'OpenAPI', extensions: ['json', 'yaml', 'yml'] }] }); + const files = Array.isArray(result) ? result : result?.filePaths; + if (!files?.length) { clear(); return []; } + return grant(files); +} +function bridge(ztools) { + if (typeof ztools?.onPluginOut === 'function') ztools.onPluginOut(clear); + return Object.freeze({ choose: () => choose(ztools), readGranted, copyText: (text) => ztools?.copyText?.(String(text)) }); +} +if (typeof window !== 'undefined') window.contractGate = bridge(window.ztools); +module.exports = { bridge, __testGrant: grant, __testClear: clear, __testGrants: () => grants, 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..5ba35362 --- /dev/null +++ b/plugins/openapi-contract-gate/test/contract.test.mjs @@ -0,0 +1,213 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { compareContracts, 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('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('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), /cycle/); + const bad = structuredClone(base); + bad.components.parameters.query = { $ref: '#/components/parameters/missing' }; + assert.throws(() => compareContracts(bad, base), /Invalid local \$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('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('Optional parameter'))); + assert.ok(levels.some((item) => item.reason.includes('Optional request property'))); + 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 anchors, aliases and explicit tags/); + } + assert.throws(() => parseDocument('openapi: 3.1.0\npaths: {}')); +}); + +test('markdown escapes untrusted fields and pointers are escaped', () => { + const markdown = reportMarkdown([{ level: 'breaking', kind: '*kind*', pointer: '/a/~b', reason: '\ntext' }]); + assert.match(markdown, /\\\*kind\\\*/); + assert.match(markdown, /\\ text/); + 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('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 consumed = preload.__testGrants()[0]; + preload.readGranted(); + assert.equal(preload.__testGrants().length, 0); + assert.throws(() => fs.fstatSync(consumed.fd), { code: 'EBADF' }); + preload.__testGrant([fixture]); + 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(), /expired/); + assert.equal(preload.__testGrants().length, 0); +}); + +test('renderer uses DOM text and path contract is cross-platform', () => { + assert.equal(fs.readFileSync(new URL('../src/main/app.js', import.meta.url), 'utf8').includes('innerHTML'), false); + 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/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: ! {} From e7186907e83e1059427f40739b09c8893eb5f2da Mon Sep 17 00:00:00 2001 From: wangzihao Date: Tue, 1 Sep 2026 16:20:35 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20OpenAPI=20?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E9=97=A8=E7=A6=81=E4=B8=8E=20MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 完善契约兼容性比较、会话授权与文件关闭重试 - 提供内联及已授权契约的分页门禁工具 AI-Co-Authored-By: Codex --- plugins/openapi-contract-gate/CHANGELOG.md | 11 +- plugins/openapi-contract-gate/README.md | 23 +- plugins/openapi-contract-gate/plugin.json | 46 +- .../scripts/dist-size.mjs | 39 ++ .../scripts/verify-dist.mjs | 31 +- .../src/core/contract.js | 190 +++++-- plugins/openapi-contract-gate/src/main/app.js | 119 ++++- .../openapi-contract-gate/src/main/index.html | 2 +- .../openapi-contract-gate/src/main/style.css | 2 +- .../src/preload/index.cjs | 385 ++++++++++++-- .../test/contract.test.mjs | 473 +++++++++++++++++- .../test/dist-size.test.mjs | 59 +++ .../test/host-contract.test.mjs | 41 ++ 13 files changed, 1319 insertions(+), 102 deletions(-) create mode 100644 plugins/openapi-contract-gate/scripts/dist-size.mjs create mode 100644 plugins/openapi-contract-gate/test/dist-size.test.mjs create mode 100644 plugins/openapi-contract-gate/test/host-contract.test.mjs diff --git a/plugins/openapi-contract-gate/CHANGELOG.md b/plugins/openapi-contract-gate/CHANGELOG.md index 1926c442..5090cc74 100644 --- a/plugins/openapi-contract-gate/CHANGELOG.md +++ b/plugins/openapi-contract-gate/CHANGELOG.md @@ -1,5 +1,12 @@ -# Changelog +# 更新日志 ## 0.1.0 -- Initial local OpenAPI/Swagger comparison gate. +- 提供本地 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 index 0845771a..fd86ea56 100644 --- a/plugins/openapi-contract-gate/README.md +++ b/plugins/openapi-contract-gate/README.md @@ -1,7 +1,22 @@ -# OpenAPI Contract Gate +# OpenAPI 契约门禁 -An offline contract ledger for OpenAPI 3 and Swagger 2 JSON or conservative YAML. It compares endpoints, methods, parameters, request bodies, responses, security, schema required fields, types, and enums, with JSON Pointer evidence for each finding. +一个适用于 OpenAPI 3、Swagger 2 JSON 及保守 YAML 的离线契约台账。它会比较接口端点、请求方法、参数、请求体、响应、安全要求、Schema 必填字段、类型和枚举,并为每项结果提供 JSON Pointer 证据。 -YAML is parsed only in the preload boundary. Normal mappings, sequences, quoted values, and block scalars are accepted; anchors, aliases, explicit tags, duplicate keys, and remote `$ref` values are rejected instead of being resolved. Files are capped at 10 MiB, depth 60, and 40,000 audited nodes. +YAML 只在 preload 边界解析。普通映射、序列、带引号值和块标量可以使用;锚点、别名、显式标签、重复键及远程 `$ref` 会被拒绝而不会继续解析。单个文件最大 10 MiB,嵌套深度不超过 60,审计节点不超过 40,000 个。 -Node contract tests, packaged-dependency checks, source/dist identity, and Chromium rendering are verified. Loading and file-dialog behavior in real Windows, macOS, and Linux ZTools hosts remain untested. +已验证 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/plugin.json b/plugins/openapi-contract-gate/plugin.json index 049e89f9..487fbd7e 100644 --- a/plugins/openapi-contract-gate/plugin.json +++ b/plugins/openapi-contract-gate/plugin.json @@ -1 +1,45 @@ -{"name":"openapi-contract-gate","title":"OpenAPI 契约门禁","version":"0.1.0","description":"Offline OpenAPI compatibility ledger and breaking-change gate.","author":"harris","platform":["darwin","win32","linux"],"categories":["development"],"main":"dist/main/index.html","preload":"dist/preload/index.cjs","logo":"dist/logo.svg","development":{"main":"src/main/index.html","preload":"src/preload/index.cjs"},"features":[{"code":"compare-openapi","icon":"logo.svg","platform":["darwin","win32","linux"],"explain":"比较一到两个 OpenAPI 契约","cmds":["OpenAPI 对比","API 契约门禁"]}]} +{ + "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", "platform": ["darwin", "win32", "linux"], "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/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 index ee1b498c..946ee64e 100644 --- a/plugins/openapi-contract-gate/scripts/verify-dist.mjs +++ b/plugins/openapi-contract-gate/scripts/verify-dist.mjs @@ -1 +1,30 @@ -import{access,readFile}from'node:fs/promises';import path from'node:path';import{fileURLToPath}from'node:url';const r=path.dirname(path.dirname(fileURLToPath(import.meta.url))),d=path.join(r,'dist');for(const f of['plugin.json','main/index.html','preload/index.cjs','core/contract.js','logo.svg','preload/node_modules/yaml/package.json'])await access(path.join(d,f));if(JSON.parse(await readFile(path.join(d,'plugin.json'))).development)throw Error('development leaked');console.log('openapi-contract-gate dist verified'); +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 index 7a5daae7..a91d45bb 100644 --- a/plugins/openapi-contract-gate/src/core/contract.js +++ b/plugins/openapi-contract-gate/src/core/contract.js @@ -1,6 +1,7 @@ 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) { @@ -18,27 +19,60 @@ function auditDocument(root) { 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); - if (depth > MAX_DEPTH) throw Error('Document nesting exceeds limit'); - if (++nodes > MAX_NODES) throw Error('Document node count exceeds limit'); for (const [key, next] of Object.entries(value)) { - if (key === '$ref' && typeof next === 'string' && !next.startsWith('#/')) throw Error('Remote $ref is not allowed'); + 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('Document exceeds 10 MiB limit'); + if (utf8Length(source) > MAX_BYTES) throw Error('契约文档超过 10 MiB 限制'); let document; - try { document = JSON.parse(source); } catch (error) { throw Error(`Invalid contract: ${error.message}`); } - if (!document || typeof document !== 'object' || !(document.openapi || document.swagger)) throw Error('Requires OpenAPI 3 or Swagger 2 root'); + 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('/')}`; @@ -48,13 +82,13 @@ function resolve(value, document) { const visited = new Set(); for (let depth = 0; current?.$ref; depth++) { const ref = current.$ref; - if (depth >= MAX_DEPTH || visited.has(ref)) throw Error('Local $ref cycle exceeds limit'); - if (typeof ref !== 'string' || !ref.startsWith('#/')) throw Error('Only local $ref values are allowed'); + 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(`Invalid local $ref: ${ref}`); + if (!target || typeof target !== 'object' || !Object.prototype.hasOwnProperty.call(target, part)) throw Error(`本地 $ref 无效:${ref}`); target = target[part]; } current = target; @@ -70,6 +104,7 @@ function schemaForParameter(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) { @@ -81,14 +116,14 @@ function compareAdditionalProperties(oldValue, newValue, where, out, oldDoc, new 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'), `${direction} additional properties compatibility narrowed`)); + 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), `${direction} assertion compatibility changed`)); } +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]); @@ -107,12 +142,12 @@ function compareAssertions(oldValue, newValue, where, out, oldDoc, newDoc, direc 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), `${direction} ${name} changed and compatibility cannot be proven`)); + 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), `${direction} ${name} changed and compatibility cannot be proven`)); + if (!handled.has(name) && !metadata.has(name) && changed(oldValue[name], newValue[name])) out.push(finding('breaking', 'schema.inconclusive', pointer(where, name), `${directionLabel(direction)}约束 ${name} 发生变化,无法证明兼容性`)); } } @@ -121,7 +156,7 @@ function compareSchema(oldSchema, newSchema, where, out, oldDoc, newDoc, directi 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, `${direction} boolean schema compatibility changed`)); + if (oldValue !== newValue) out.push(finding('breaking', 'schema.boolean', where, `${directionLabel(direction)}的布尔 Schema 兼容性发生变化`)); return; } if (typeof oldValue === 'object' && typeof newValue === 'object') { @@ -132,32 +167,32 @@ function compareSchema(oldSchema, newSchema, where, out, oldDoc, newDoc, directi } 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, `${direction} type compatibility narrowed`)); + 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, `${direction} type compatibility changed: ${invalid.join(', ')}`)); + 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, `${direction} enum compatibility narrowed`)); + 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, `${direction} enum compatibility changed: ${invalid.join(', ')}`)); + 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'), `Field ${name} became required`)); - if (direction === 'response') for (const name of missing(oldRequired, newRequired)) out.push(finding('breaking', 'schema.required', pointer(where, 'required'), `Response field ${name} is no longer 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), 'Response property removed')); - if (direction === 'request') out.push(finding('breaking', 'schema.property', pointer(where, 'properties', name), 'Accepted request property removed')); + 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), 'Optional request property added')); + 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) { @@ -171,6 +206,7 @@ function parameters(operation, pathItem, document) { 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))); } @@ -178,19 +214,19 @@ function sameSecurity(left, right) { return JSON.stringify(left) === JSON.string 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, 'Operation allows anonymous access')); - else if (oldSecurity.length === 0) out.push(finding('breaking', 'security', where, 'Operation now requires security')); - else out.push(finding('breaking', 'security', where, 'Effective security requirements changed')); + 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, 'New required request body')); return; } - if (oldBody && !newBody) { out.push(finding('breaking', 'requestBody.content', where, 'Request body support removed')); return; } + 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, 'Request body became required')); + 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), `Accepted content type ${type} removed`)); + 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'); } } @@ -204,59 +240,123 @@ function compareResponse(oldResponse, newResponse, where, out, oldDoc, newDoc) { return; } for (const [type, media] of Object.entries(oldContent || {})) { - if (!newContent?.[type]) out.push(finding('breaking', 'response.content', pointer(where, 'content', type), `Response content type ${type} removed`)); + 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'); } } -export function compareContracts(oldDoc, newDoc) { - const out = [], oldPaths = oldDoc.paths || {}, newPaths = newDoc.paths || {}; +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), 'Endpoint removed')); continue; } + 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, 'Method removed')); continue; } + 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, `Parameter ${key} removed`)); + if (!next) out.push(finding('breaking', 'parameter', where, `参数 ${key} 已移除`)); else { - if (!parameter.required && next.required) out.push(finding('breaking', 'parameter.required', where, `Parameter ${key} became required`)); + 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 ? `New required parameter ${key}` : `Optional parameter ${key} added`)); + 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), `Response ${status} removed`)); + 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 ${method.toUpperCase()} added`)); + 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), `Endpoint added (${Object.keys(pathItem).filter((key) => METHODS.includes(key)).join(', ')})`)); - return out; + 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']; - return ['# OpenAPI Contract Gate', '', ...groups.flatMap((group) => { - const items = findings.filter((item) => item.level === group).map((item) => `- **${escapeMarkdown(item.kind)}** at \`${escapeMarkdown(item.pointer)}\`: ${escapeMarkdown(item.reason)}`); - return [`## ${group}`, ...(items.length ? items : ['- None'])]; + 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 index 60996ede..4a9b5e82 100644 --- a/plugins/openapi-contract-gate/src/main/app.js +++ b/plugins/openapi-contract-gate/src/main/app.js @@ -1 +1,118 @@ -import{parseDocument,compareContracts,reportMarkdown}from'../core/contract.js';const ledger=document.querySelector('#ledger');const $=s=>document.querySelector(s);let findings=[];function entry(f){const a=document.createElement('article'),b=document.createElement('b'),br1=document.createElement('br'),code=document.createElement('code'),br2=document.createElement('br');a.className=`entry ${f.level}`;b.textContent=`${f.level} · ${f.kind}`;code.textContent=f.pointer;a.append(b,br1,code,br2,document.createTextNode(f.reason));return a;}function render(){ledger.replaceChildren(...(findings.length?findings.map(entry):[Object.assign(document.createElement('article'),{className:'entry non-breaking',textContent:'No behavioral difference found.'})]));}function run(oldText,nextText){findings=compareContracts(parseDocument(oldText),parseDocument(nextText));render();}function error(e){ledger.replaceChildren(Object.assign(document.createElement('article'),{className:'entry',textContent:e.message}));}$('#compare').onclick=()=>{try{run($('#old').value,$('#next').value);}catch(e){error(e);}};$('#copy-md').onclick=()=>window.contractGate?.copyText?.(reportMarkdown(findings));$('#copy-json').onclick=()=>window.contractGate?.copyText?.(JSON.stringify(findings,null,2));$('#choose').onclick=async()=>{try{await window.contractGate?.choose?.();const docs=window.contractGate?.readGranted?.();if(!docs)throw Error('ZTools bridge unavailable');if(docs[0])$('#old').value=docs[0];if(docs[1])$('#next').value=docs[1];if(docs.length===2)run(docs[0],docs[1]);}catch(e){error(e);}}; +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 index eeec2020..6b74cb6d 100644 --- a/plugins/openapi-contract-gate/src/main/index.html +++ b/plugins/openapi-contract-gate/src/main/index.html @@ -1 +1 @@ -OpenAPI Contract Gate
CONTRACT LEDGER

Compatibility, itemized.

API
+OpenAPI 契约门禁
契约变更台账

逐项核对兼容性

API
diff --git a/plugins/openapi-contract-gate/src/main/style.css b/plugins/openapi-contract-gate/src/main/style.css index 47c33e51..c8371c1d 100644 --- a/plugins/openapi-contract-gate/src/main/style.css +++ b/plugins/openapi-contract-gate/src/main/style.css @@ -1 +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}.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}button{border:1px solid #1d623d;background:#1d623d;color:white;padding:12px 17px;font-weight:bold;cursor:pointer}#ledger{min-width:0;margin-top:30px;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}} +: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 index daf797e7..32620276 100644 --- a/plugins/openapi-contract-gate/src/preload/index.cjs +++ b/plugins/openapi-contract-gate/src/preload/index.cjs @@ -1,46 +1,164 @@ 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 close(record) { try { fs.closeSync(record.fd); } catch {} } -function clear() { for (const record of grants) close(record); grants = []; } +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('Contract exceeds safe structure limits'); + 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)) { - queue.push([key, depth + 1]); - if (key === '$ref' && typeof next === 'string' && !next.startsWith('#/')) throw Error('Remote $ref is not allowed'); + if (key === '$ref' && typeof next === 'string' && !next.startsWith('#/')) throw Error('不允许远程 $ref'); queue.push([next, depth + 1]); } } } -function record(file) { +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('Rejected contract file'); - return { fd, real, size: stat.size, mtime: stat.mtimeMs, dev: stat.dev, ino: stat.ino, until: Date.now() + TTL }; - } catch (error) { close({ fd }); throw error; } + 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) { +function grant(files, ttl = TTL) { clear(); - if (!Array.isArray(files) || files.length < 1 || files.length > 2) throw Error('Choose one or two contract files'); + 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)); grants = selected; return grants.map((item) => path.basename(item.real)); } - catch (error) { for (const item of selected) close(item); throw error; } + 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 anchors, aliases and explicit tags are not allowed'); + 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); @@ -49,39 +167,242 @@ 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 anchors, aliases and explicit tags are not allowed'); - throw Error(message); + 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) { clear(); throw Error('Selection expired'); } - const stat = fs.fstatSync(record.fd); - if (stat.size !== record.size || stat.mtimeMs !== record.mtime || stat.dev !== record.dev || stat.ino !== record.ino) throw Error('Contract changed after selection'); - const buffer = Buffer.alloc(stat.size); let offset = 0; - while (offset < buffer.length) { const count = fs.readSync(record.fd, buffer, offset, buffer.length - offset, offset); if (!count) throw Error('Incomplete read'); offset += count; } - const document = /\.json$/i.test(record.real) ? JSON.parse(buffer.toString('utf8')) : parseYaml(buffer.toString('utf8')); + 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('Contract exceeds safe serialized size limit'); + if (Buffer.byteLength(serialized) > MAX) throw Error('契约超过安全序列化大小限制'); return serialized; } -function readGranted() { - if (!grants.length) throw Error('Choose contract files first'); +function readGranted({ consume = false } = {}) { + if (!grants.length) throw Error('请先选择契约文件'); try { return grants.map(read); } - finally { clear(); } + catch (error) { clear(); throw error; } + finally { if (consume) clear(); } } async function choose(ztools) { - if (typeof ztools?.showOpenDialog !== 'function') throw Error('ZTools file dialog unavailable'); - const result = await ztools.showOpenDialog({ properties: ['openFile', 'multiSelections'], filters: [{ name: 'OpenAPI', extensions: ['json', 'yaml', 'yml'] }] }); + 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 []; } - return grant(files); + try { + const names = grant(files); + assertSessionEpoch(epoch); + return names; + } + catch { clear(); throw Error('无法授权所选契约文件,请检查文件类型、大小和权限'); } } function bridge(ztools) { - if (typeof ztools?.onPluginOut === 'function') ztools.onPluginOut(clear); - return Object.freeze({ choose: () => choose(ztools), readGranted, copyText: (text) => ztools?.copyText?.(String(text)) }); + 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 = { bridge, __testGrant: grant, __testClear: clear, __testGrants: () => grants, readGranted }; +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 index 5ba35362..35c3d67e 100644 --- a/plugins/openapi-contract-gate/test/contract.test.mjs +++ b/plugins/openapi-contract-gate/test/contract.test.mjs @@ -1,9 +1,10 @@ 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, parseDocument, pathContract, reportMarkdown } from '../src/core/contract.js'; +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'); @@ -11,6 +12,133 @@ const doc = (operation, extra = {}) => ({ openapi: '3.1.0', paths: { '/pets/{id} 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'] } } } } } })); @@ -31,6 +159,47 @@ test('Swagger 2 non-body parameter type and enum are compared directly', () => { 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' } } } } })); @@ -129,10 +298,10 @@ test('local refs decode JSON Pointer and resolve all comparison entry points', ( 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), /cycle/); + assert.throws(() => compareContracts(cycle, base), /循环引用/); const bad = structuredClone(base); bad.components.parameters.query = { $ref: '#/components/parameters/missing' }; - assert.throws(() => compareContracts(bad, base), /Invalid local \$ref/); + assert.throws(() => compareContracts(bad, base), /本地 \$ref 无效/); }); test('effective global and operation security respects explicit empty arrays', () => { @@ -147,6 +316,17 @@ test('effective global and operation security respects explicit empty arrays', ( 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 = { @@ -160,8 +340,8 @@ test('reports non-breaking methods, optional parameters and properties', () => { } }; const levels = compareContracts(before, after).filter((item) => item.level === 'non-breaking'); - assert.ok(levels.some((item) => item.reason.includes('Optional parameter'))); - assert.ok(levels.some((item) => item.reason.includes('Optional request property'))); + assert.ok(levels.some((item) => item.reason.includes('新增可选参数'))); + assert.ok(levels.some((item) => item.reason.includes('新增可选请求属性'))); assert.ok(levels.some((item) => item.kind === 'method')); }); @@ -171,40 +351,305 @@ test('YAML is parsed only in preload and aliases/tags are rejected there', () => 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 anchors, aliases and explicit tags/); + 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: '*kind*', pointer: '/a/~b', reason: '\ntext' }]); - assert.match(markdown, /\\\*kind\\\*/); + 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 consumed = preload.__testGrants()[0]; + const preserved = preload.__testGrants()[0]; preload.readGranted(); - assert.equal(preload.__testGrants().length, 0); - assert.throws(() => fs.fstatSync(consumed.fd), { code: 'EBADF' }); - preload.__testGrant([fixture]); + 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(), /expired/); + 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', () => { - assert.equal(fs.readFileSync(new URL('../src/main/app.js', import.meta.url), 'utf8').includes('innerHTML'), false); + 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/); 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/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'/); +}); From aa5227dd6b1a78f53edf41aaceb2762eec8e6f48 Mon Sep 17 00:00:00 2001 From: wangzihao Date: Tue, 1 Sep 2026 17:27:37 +0800 Subject: [PATCH 3/4] fix(ztools-spec): fix platform placement and preload module declaration --- plugins/openapi-contract-gate/package.json | 18 ++++- plugins/openapi-contract-gate/plugin.json | 76 ++++++++++++++++++---- 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/plugins/openapi-contract-gate/package.json b/plugins/openapi-contract-gate/package.json index 88af8260..3a440573 100644 --- a/plugins/openapi-contract-gate/package.json +++ b/plugins/openapi-contract-gate/package.json @@ -1 +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"}} +{ + "name": "openapi-contract-gate", + "version": "0.1.0", + "type": "commonjs", + "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 index 487fbd7e..5e6768de 100644 --- a/plugins/openapi-contract-gate/plugin.json +++ b/plugins/openapi-contract-gate/plugin.json @@ -4,12 +4,28 @@ "version": "0.1.0", "description": "离线 OpenAPI 兼容性台账与破坏性变更门禁。", "author": "harris", - "platform": ["darwin", "win32", "linux"], - "categories": ["development"], + "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", "platform": ["darwin", "win32", "linux"], "explain": "比较一到两个 OpenAPI 契约", "cmds": ["OpenAPI 对比", "API 契约门禁"] }], + "features": [ + { + "code": "compare-openapi", + "icon": "logo.svg", + "explain": "比较一到两个 OpenAPI 契约", + "cmds": [ + "OpenAPI 对比", + "API 契约门禁" + ] + } + ], "tools": { "compare_inline": { "title": "比较内联 OpenAPI 契约", @@ -18,14 +34,39 @@ "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 } + "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"] + "required": [ + "before", + "after" + ] } }, "compare_approved_files": { @@ -35,9 +76,18 @@ "type": "object", "additionalProperties": false, "properties": { - "includeMarkdown": { "type": "boolean" }, - "offset": { "type": "integer", "minimum": 0 }, - "limit": { "type": "integer", "minimum": 1, "maximum": 200 } + "includeMarkdown": { + "type": "boolean" + }, + "offset": { + "type": "integer", + "minimum": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200 + } } } } From 62012b82ce17b2a44f1a0e4c6815751c43c9fecb Mon Sep 17 00:00:00 2001 From: wangzihao Date: Tue, 1 Sep 2026 17:30:35 +0800 Subject: [PATCH 4/4] fix(package): restore package.json type module for esm tests --- plugins/openapi-contract-gate/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/openapi-contract-gate/package.json b/plugins/openapi-contract-gate/package.json index 3a440573..4436e2a2 100644 --- a/plugins/openapi-contract-gate/package.json +++ b/plugins/openapi-contract-gate/package.json @@ -1,7 +1,7 @@ { "name": "openapi-contract-gate", "version": "0.1.0", - "type": "commonjs", + "type": "module", "private": true, "scripts": { "test": "node --test",