From d9d6459e96f78450385844ce17bd111ef2fc5a7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 11:51:13 +0000 Subject: [PATCH 1/2] test(downgrader): cover restriction-loss and conversion gaps found by mutation testing Covers lost restrictions carried through allOf, anyOf, properties, not and oneOf; propertyNames and unevaluatedProperties edge cases; contentMediaType in an anyOf branch; contentEncoding "binary"; enum [null, "a"]; and a 3.2 to 3.1 response $ref staying as written. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EjMyAKtd1J94kr1PfQeQza --- .../downgrader/tests/v3.1-to-v3.0.test.ts | 48 +++++++++++++++++++ .../downgrader/tests/v3.2-to-v3.1.test.ts | 2 + 2 files changed, 50 insertions(+) diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index 9b9d80a..fbaf4cd 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -495,6 +495,7 @@ describe('downgradeSchemaV31ToV30', () => { ]) expect(downgradeSchemaV31ToV30({ type: 'null', enum: [null, 'a'] })).toEqual(onlyNull) expect(downgradeSchemaV31ToV30({ enum: [null] })).toEqual(onlyNull) + expect(downgradeSchemaV31ToV30({ enum: [null, 'a'] })).toEqual({ enum: [null, 'a'] }) // Only null may match the type, and the enum rules it out. expect(downgradeSchemaV31ToV30({ type: 'null', enum: ['a'] })).toEqual({ enum: ['a'], @@ -784,6 +785,10 @@ describe('downgradeSchemaV31ToV30', () => { type: 'string', format: 'binary', }) + expect(downgradeSchemaV31ToV30({ type: 'string', contentEncoding: 'binary' })).toEqual({ + type: 'string', + format: 'binary', + }) expect( downgradeSchemaV31ToV30({ type: 'string', @@ -820,6 +825,11 @@ describe('downgradeSchemaV31ToV30', () => { ], }), ).toEqual({}) + expect( + downgradeSchemaV31ToV30({ + anyOf: [{ type: 'string', contentMediaType: 'image/png' }, { type: 'integer' }], + }), + ).toEqual({ anyOf: [{ type: 'string', format: 'binary' }, { type: 'integer' }] }) }) describe('$ref', () => { @@ -1145,6 +1155,44 @@ describe('downgradeSchemaV31ToV30', () => { ).toEqual({ anyOf: [{ minimum: 0 }], allOf: [{ anyOf: [{ items: {} }, {}] }] }) }) + it('includes propertyNames beyond a string type, and unevaluatedProperties beside an in-place applicator', () => { + // As zod emits z.record with a key pattern. + expect( + downgradeSchemaV31ToV30({ + not: { type: 'object', propertyNames: { type: 'string', pattern: '^a' } }, + }), + ).toEqual({}) + expect( + downgradeSchemaV31ToV30({ + not: { allOf: [{ properties: { a: {} } }], unevaluatedProperties: false }, + }), + ).toEqual({}) + }) + + it('excludes propertyNames: true, and unevaluatedProperties beside additionalProperties', () => { + expect(downgradeSchemaV31ToV30({ not: { type: 'object', propertyNames: true } })).toEqual({ + not: { type: 'object' }, + }) + expect( + downgradeSchemaV31ToV30({ + not: { additionalProperties: { type: 'string' }, unevaluatedProperties: false }, + }), + ).toEqual({ not: { additionalProperties: { type: 'string' } } }) + }) + + it('carries over to a schema around it, through allOf, anyOf, properties, not, and oneOf', () => { + const lost = { type: 'object', propertyNames: { pattern: '^a' } } as const + expect(downgradeSchemaV31ToV30({ not: { allOf: [lost, { required: ['a'] }] } })).toEqual({}) + expect(downgradeSchemaV31ToV30({ not: { anyOf: [lost, { type: 'string' }] } })).toEqual({}) + expect( + downgradeSchemaV31ToV30({ not: { properties: { a: lost, b: { type: 'string' } } } }), + ).toEqual({}) + expect(downgradeSchemaV31ToV30({ not: { not: lost } })).toEqual({}) + expect( + downgradeSchemaV31ToV30({ not: { anyOf: [{ minimum: 0 }], oneOf: [lost, {}] } }), + ).toEqual({}) + }) + it('excludes keywords that restrict nothing, and tuples converted exactly', () => { // As zod emits a discriminated union whose member holds a record or a tuple. const record: OpenAPIV3_1.SchemaObject = { diff --git a/packages/downgrader/tests/v3.2-to-v3.1.test.ts b/packages/downgrader/tests/v3.2-to-v3.1.test.ts index 2021069..34ac525 100644 --- a/packages/downgrader/tests/v3.2-to-v3.1.test.ts +++ b/packages/downgrader/tests/v3.2-to-v3.1.test.ts @@ -359,12 +359,14 @@ describe('downgradeSpecV32ToV31', () => { 200: { summary: 'OK', description: 'Everything went fine' }, 201: { summary: 'Created' }, 204: {}, + 404: { $ref: '#/components/responses/NotFound' }, }, }) expect(out?.responses).toEqual({ 200: { description: 'Everything went fine' }, 201: { description: 'Created' }, 204: { description: '' }, + 404: { $ref: '#/components/responses/NotFound' }, }) }) From 57c9f188d3e0d8978f13e38b279980d3f217f6f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:24:43 +0000 Subject: [PATCH 2/2] test(downgrader): check for undefined-valued keys and cover typed const and examples gaps expectValidDowngrade now checks the output holds only what JSON keeps, so a key holding undefined fails. Adds a typed const, an empty examples list, and a oneOf branch with an undefined-valued keyword. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EjMyAKtd1J94kr1PfQeQza --- packages/downgrader/tests/v3.1-to-v3.0.test.ts | 9 +++++++++ packages/downgrader/tests/validate.ts | 2 ++ 2 files changed, 11 insertions(+) diff --git a/packages/downgrader/tests/v3.1-to-v3.0.test.ts b/packages/downgrader/tests/v3.1-to-v3.0.test.ts index fbaf4cd..629c267 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0.test.ts @@ -625,6 +625,11 @@ describe('downgradeSchemaV31ToV30', () => { nullable: true, enum: [null], }) + // As Zod emits `z.literal(5)`: the written type stays, though 5 is an integer. + expect(downgradeSchemaV31ToV30({ type: 'number', const: 5 })).toEqual({ + type: 'number', + enum: [5], + }) }) it('types a const with no type after its value', () => { @@ -687,6 +692,7 @@ describe('downgradeSchemaV31ToV30', () => { it('turns examples into example', () => { expect(downgradeSchemaV31ToV30({ examples: ['a', 'b'] })).toEqual({ example: 'a' }) expect(downgradeSchemaV31ToV30({ example: 'x', examples: ['a'] })).toEqual({ example: 'x' }) + expect(downgradeSchemaV31ToV30({ examples: [] })).toStrictEqual({}) }) it('turns tuples into arrays whose items match any item schema', () => { @@ -1531,6 +1537,9 @@ describe('unusual input', () => { ], }), ).toEqual({ oneOf: [{ type: 'object' }, { type: 'string' }] }) + expect( + downgradeSchemaV31ToV30({ oneOf: [{ type: 'object', if: undefined }, { type: 'string' }] }), + ).toEqual({ oneOf: [{ type: 'object' }, { type: 'string' }] }) }) it('tolerates malformed input without throwing', () => { diff --git a/packages/downgrader/tests/validate.ts b/packages/downgrader/tests/validate.ts index 510c345..3d1d717 100644 --- a/packages/downgrader/tests/validate.ts +++ b/packages/downgrader/tests/validate.ts @@ -92,6 +92,8 @@ export async function expectValidDowngrade( const before = structuredClone(doc) const output = downgrade(doc) await expectValidAs(output, to) + // It holds only what JSON keeps, with no key holding `undefined`. + expect(output).toStrictEqual(JSON.parse(JSON.stringify(output))) expectNoNewDanglingRefs(doc, output) expect(doc).toEqual(before) return output