From 9edc44c17780977a20508c49e80477a91ec892c0 Mon Sep 17 00:00:00 2001 From: rowkav09 Date: Mon, 5 Oct 2026 08:03:18 +0100 Subject: [PATCH] fix: retain simultaneous primitive composition constraints I preserve both anyOf and oneOf when they appear together on a primitive parameter schema instead of silently choosing oneOf and dropping anyOf. I added fail-first validation regressions for all parameter locations. --- src/manifest.ts | 28 ++++++++++++++----------- test/parameter-composition-root.test.ts | 16 ++++++++++++++ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/manifest.ts b/src/manifest.ts index d362c25..e05565e 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -160,21 +160,25 @@ const JSON_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'array', ' * compositions from the loaded parameter schema when Forge narrows them. */ function primitiveComposition(schema: Record | undefined): Record | undefined { if (!schema) return undefined; - const key = Array.isArray(schema.oneOf) ? 'oneOf' : Array.isArray(schema.anyOf) ? 'anyOf' : undefined; - if (!key) return undefined; - const branches = schema[key] as unknown[]; - if (branches.length === 0) return undefined; - const mapped = branches.map((branch) => { - const node = resolveDocRef(branch) as Record | undefined; - if (!node || typeof node.type !== 'string' || !['string', 'number', 'integer', 'boolean', 'null'].includes(node.type)) return undefined; - const { $ref: _ref, nullable: _nullable, xml: _xml, ...constraints } = node; - return constraints; - }); - if (mapped.some((branch) => branch === undefined)) return undefined; + const keys = ['anyOf', 'oneOf'].filter(key => Array.isArray(schema[key])); + if (keys.length === 0) return undefined; + const compositions: Record = {}; + for (const key of keys) { + const branches = schema[key] as unknown[]; + if (branches.length === 0) return undefined; + const mapped = branches.map((branch) => { + const node = resolveDocRef(branch) as Record | undefined; + if (!node || typeof node.type !== 'string' || !['string', 'number', 'integer', 'boolean', 'null'].includes(node.type)) return undefined; + const { $ref: _ref, nullable: _nullable, xml: _xml, ...constraints } = node; + return constraints; + }); + if (mapped.some((branch) => branch === undefined)) return undefined; + compositions[key] = mapped; + } return { ...(typeof schema.type === 'string' ? { type: schema.type } : {}), ...(Array.isArray(schema.enum) ? { enum: schema.enum } : {}), - [key]: mapped, + ...compositions, }; } diff --git a/test/parameter-composition-root.test.ts b/test/parameter-composition-root.test.ts index 513465f..d8328c9 100644 --- a/test/parameter-composition-root.test.ts +++ b/test/parameter-composition-root.test.ts @@ -26,3 +26,19 @@ for (const location of ['path', 'query', 'header', 'cookie']) { assert.equal(validate({ ...args, [arg.name]: 'safe' }), false); }); } + +for (const location of ['path', 'query', 'header', 'cookie']) { + test(`primitive ${location} parameters retain simultaneous anyOf and oneOf`, async () => { + const doc = { openapi: '3.1.0', info: { title: 'Intersections', version: '1' }, components: { schemas: {} }, + paths: { [location === 'path' ? '/x/{v}' : '/x']: { get: { operationId: 'x', parameters: [{ name: 'v', in: location, required: true, schema: { + anyOf: [{ type: 'integer', minimum: 5 }], oneOf: [{ type: 'integer' }, { type: 'string' }], + } }], responses: { '200': { description: 'OK' } } } } }, + } as unknown as OpenAPIV3.Document; + await init(doc); + const tool = buildManifest(doc).tools[0]!; + const validate = compileOutputValidator(JSON.parse(JSON.stringify(tool.inputSchema))); + assert.equal(validate({ v: 5 }), true); + assert.equal(validate({ v: 4 }), false); + assert.equal(validate({ v: 'safe' }), false); + }); +}