Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
c848f6e
test(downgrader): cover the cut-$ref reset, writeOnly and $ref tags, …
claude Nov 2, 2026
17b0cc9
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
90d9ae5
fix(downgrader): leave out a 3.2 security requirement name whose sche…
claude Nov 2, 2026
71b348d
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
720cf6e
test(downgrader): cover a 3.2 security requirement name holding undef…
claude Nov 2, 2026
de5d602
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
6c5d811
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
85e0979
test(downgrader): cover a null branch beside enums in a malformed anyOf
claude Nov 2, 2026
dd95369
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
170d121
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
4ea0add
test(downgrader): keep the allOf beside a restricting keyword on a ta…
claude Nov 2, 2026
eef3ee2
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
c7b4e1b
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
7531a72
docs(downgrader): a $ref into an anyOf removed for a media-type-only …
claude Nov 2, 2026
4c4b7bf
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
946a4f6
docs(downgrader): a $ref into a removed not dangles
claude Nov 2, 2026
ae2be47
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
69c1aae
docs(downgrader): a $ref into a 3.2 itemSchema that moves into schema…
claude Nov 2, 2026
91533fb
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
f4f0c9b
docs(downgrader): a $ref by index past a removed 3.2 querystring para…
claude Nov 2, 2026
74e3b3b
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
2360af7
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
25fb9ca
fix(downgrader): keep a $ref bare beside only a 3.0-style nullable
claude Nov 2, 2026
5658ff8
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
ffa455e
fix(downgrader): keep a $ref bare beside identifiers 3.0 removes
claude Nov 2, 2026
114cae7
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
24cfba2
fix(downgrader): keep null in a loop of nullable $ref pairs
claude Nov 2, 2026
bc1fe15
fix(downgrader): follow nullable pairs beside annotations 3.0 drops
claude Nov 2, 2026
f3cc671
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
47d2d6c
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
f7cdda7
fix(downgrader): drop a discriminator that a $ref or type list would …
claude Nov 2, 2026
660afb1
docs(downgrader): note the discriminator dropped beside a $ref or typ…
claude Nov 2, 2026
6718cbd
fix(downgrader): keep a $ref bare beside only a removed discriminator
claude Nov 2, 2026
272eaf2
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
80c69ae
test(downgrader): keep a discriminator beside a $ref and an anyOf or …
claude Nov 2, 2026
a8c2c54
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
c80c90c
fix(downgrader): follow a null pair nested in a null pair around a $r…
claude Nov 2, 2026
e47c94c
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
96fe14d
fix(downgrader): stop the nested pair walk at a cycle of objects
claude Nov 2, 2026
7645f9b
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
b3e2536
test(downgrader): follow a $ref beside an object cycle in a nested nu…
claude Nov 2, 2026
dbe21f7
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
956f651
fix(downgrader): keep a null pair only when its $ref would loop back …
claude Nov 2, 2026
637a684
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
587947c
refactor(downgrader): follow any $ref in the 3.0 loop check, and test…
claude Nov 2, 2026
5fb05d0
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
d8db861
test(downgrader): pin a oneOf pair to a null-admitting $ref, and null…
claude Nov 2, 2026
ca238b9
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
6f6a0ed
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
be4ac8b
test(downgrader): pin an enum branch beside null, a $ref beside a nul…
claude Nov 2, 2026
abcbc8f
test(downgrader): clearer comment and name in the $ref-beside-type case
claude Nov 2, 2026
9871b22
Merge origin/main
claude Nov 2, 2026
e31503e
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 3, 2026
26dfa99
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 4, 2026
28eee96
test(downgrader): pin that a bare null in a type list counts only as …
claude Nov 4, 2026
cb070bd
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 4, 2026
cfb8f0b
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 4, 2026
b4b66a2
test(downgrader): pin that a $ref into a kept key or another componen…
claude Nov 4, 2026
9928840
test(downgrader): assert the kept $ref targets survive
claude Nov 4, 2026
a9c9531
Merge main
claude Nov 4, 2026
92d20db
fix(downgrader): convert $ref targets kept in an extension
claude Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/downgrader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ The input is never changed, and the output shares no plain objects or arrays wit

External `$ref`s are left as written, except in a 3.2 `content` map, where 3.1 allows none and the entry becomes `{}`, which takes any payload. The files they point at are not converted, so bundle a multi-file document into one first.

Extensions pass through unconverted, so a `$ref` into one at the document root, in `components`, or right under a component schema, as Stoplight writes when it bundles files into `x-ext`, is replaced by its converted target.

## 3.2 → 3.1

### Spec (`downgradeSpecV32ToV31`)
Expand Down Expand Up @@ -265,7 +267,7 @@ Accepts the default OAS dialect, which adds `discriminator`, `xml`, `externalDoc
- An `anyOf` or `oneOf` of `{ type: "null" }` and a branch that a `$ref` points into stays as written, so the `$ref` still resolves, and code generators read it as a union.
- `$ref`s into a keyword 3.0 removes, such as `prefixItems/0` or `propertyNames`, into a `oneOf` that becomes `anyOf`, into a `not` removed over a schema that lost a restriction, into an `anyOf` removed for a branch that holds only `contentMediaType`, or into the `items` or `properties` of a schema whose `type` list names more than one type besides `"null"`, which move into the branches of the `anyOf` or `oneOf` that takes the place of the list, are left as written and dangle. A `$ref` into the `allOf` of a schema that also holds a `$ref` resolves one member off, since that `$ref` becomes the first member of the `allOf`.
- Recursion through `$defs` is cut to `{}` after one level. Where several `$ref`s enter the same cycle, the first copy made is reused, so a later one can be cut sooner. Such a cut `$ref` tells no `oneOf` branch apart, and counts as taking null, wherever its copy is reused.
- Each `$ref` into `$defs` repeats its target once serialized, so definitions that reference each other in many layers make a large document, or one too large to serialize: SchemaStore's Kestra flow schema, 4 MB as compact JSON, would serialize to about 84 TB. In a document, move such definitions to `components/schemas`, where `$ref`s stay as written.
- Each `$ref` into `$defs` or an extension repeats its target once serialized, so definitions that reference each other in many layers make a large document, or one too large to serialize: SchemaStore's Kestra flow schema, 4 MB as compact JSON, would serialize to about 84 TB. In a document, move such definitions to `components/schemas`, where `$ref`s stay as written.
- Older drafts are not supported. `definitions` is read as `$defs`, but other keywords that only draft-07 or 2019-09 define, such as `dependencies` and `additionalItems`, become extensions unconverted, so `$ref`s into them dangle, except right under a component schema, and array-form `items` passes through. Convert such schemas to 2020-12 first. oRPC 1.x's Valibot converter writes draft-07 unless it is given `{ target: 'draft-2020-12' }`. The Rust crate aide writes draft-07 through schemars in its 3.1 documents, so a Rust tuple or tuple enum variant becomes array-form `items`, unless `aide::generate::in_context` sets its schema generator to `SchemaSettings::draft2020_12()`, with `definitions_path` kept at `#/components/schemas/`. TypeBox, up to 1.4, also writes `Type.Tuple` as array-form `items`, as Elysia's OpenAPI plugin does in its 3.1 documents.
- `patternProperties` that matches every name, as TypeBox emits for a record, gives `additionalProperties: true`, so its value schema is lost.
- A record keyed by a union of literals, which Zod and Valibot write as an `anyOf` of `const` branches in `propertyNames`, loses its key names, so 3.0 allows any key. Key it by an enum instead, such as `z.enum` or `v.picklist`.
Expand Down
9 changes: 5 additions & 4 deletions packages/downgrader/src/v3.1-to-v3.0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,9 @@ const DOCUMENT_FIELDS = defineFields({
// `#/components/<type>/` is a name, so a component named `items` is not one.
function isInlined(ref: string, ctx: Context): boolean {
let keywords = ref
let renamed = false
// Extensions pass through unconverted, so a target kept in one, as Stoplight
// bundles files into `x-ext`, is converted where a `$ref` uses it.
let unconverted = ref.startsWith('#/x-') || ref.startsWith('#/components/x-')
if (ref.startsWith('#/components/')) {
const name = ref.indexOf('/', 13) + 1
const end = ref.indexOf('/', name)
Expand All @@ -307,12 +309,11 @@ function isInlined(ref: string, ctx: Context): boolean {
if (keywords !== '' && ref.startsWith('#/components/schemas/')) {
const next = keywords.indexOf('/', 1)
const key = next === -1 ? keywords.slice(1) : keywords.slice(1, next)
renamed =
!V30_SCHEMA_KEYWORDS.has(key) && !REMOVED_KEYWORDS.includes(key) && !key.startsWith('x-')
unconverted = !V30_SCHEMA_KEYWORDS.has(key) && !REMOVED_KEYWORDS.includes(key)
}
}
return (
(renamed ||
(unconverted ||
ref.includes('%') ||
keywords.includes('/$defs/') ||
keywords.includes('/definitions/') ||
Expand Down
40 changes: 36 additions & 4 deletions packages/downgrader/tests/v3.1-to-v3.0.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,38 @@ describe('downgradeSpecV31ToV30', () => {
})
})

describe('extensions', () => {
it('inlines $refs into an extension, whose content passes through unconverted, as Stoplight bundles into x-ext', () => {
const ext = {
tag: { const: 'a' },
loop: { type: 'object', properties: { next: { $ref: '#/x-ext/loop' } } },
query: { name: 'q', in: 'query', schema: { type: ['string', 'null'] } },
}
const out = convert({
'paths': { '/a': { get: { parameters: [{ $ref: '#/x-ext/query' }], responses } } },
'components': {
'schemas': {
Tag: { oneOf: [{ $ref: '#/x-ext/tag' }, { type: 'null' }] },
Loop: { $ref: '#/x-ext/loop' },
Kind: { $ref: '#/components/x-schemas/Kind' },
},
'x-schemas': { Kind: { const: 'b' } },
},
'x-ext': ext,
})
expect(out.paths['/a']?.get?.parameters).toEqual([
{ name: 'q', in: 'query', schema: { type: 'string', nullable: true } },
])
// A 3.0 reader would ignore `const`, so null would match both branches.
expect(out.components?.schemas).toEqual({
Tag: { oneOf: [{ type: 'string', enum: ['a'] }, onlyNull] },
Loop: { type: 'object', properties: { next: {} } },
Kind: { type: 'string', enum: ['b'] },
})
expect(out['x-ext']).toEqual(ext)
})
})

describe('mutual TLS', () => {
it('drops mutualTLS schemes, and the $refs that resolve to them', () => {
const out = convert({
Expand Down Expand Up @@ -2968,7 +3000,7 @@ describe('downgradeSchemaV31ToV30', () => {
FieldOptions: {
'Text': { type: 'object', properties: { type: { const: 'Text' } } },
'Number': { type: ['number', 'null'] },
'x-note': { type: 'string' },
'x-note': { const: 'note' },
'prefixItems': [{ type: 'string' }],
},
Field: {
Expand All @@ -2979,7 +3011,7 @@ describe('downgradeSchemaV31ToV30', () => {
},
},
Nested: ref('FieldOptions/Text/properties/type'),
Kept: ref('FieldOptions/x-note'),
Extension: ref('FieldOptions/x-note'),
Removed: ref('FieldOptions/prefixItems/0'),
// A key 3.0 keeps, and a key of another component type, are not renamed.
Negated: { not: { type: 'string' } },
Expand All @@ -2992,15 +3024,15 @@ describe('downgradeSchemaV31ToV30', () => {
// The discriminator goes, as its mapping would dangle.
expect(out.components?.schemas?.Field).not.toHaveProperty('discriminator')
expect(out.components?.schemas).toMatchObject({
FieldOptions: { 'x-Text': {}, 'x-Number': {} },
FieldOptions: { 'x-Text': {}, 'x-Number': {}, 'x-note': { const: 'note' } },
Field: {
oneOf: [
{ type: 'object', properties: { type: { type: 'string', enum: ['Text'] } } },
{ type: 'number', nullable: true },
],
},
Nested: { type: 'string', enum: ['Text'] },
Kept: ref('FieldOptions/x-note'),
Extension: { type: 'string', enum: ['note'] },
Removed: ref('FieldOptions/prefixItems/0'),
Negated: { not: { type: 'string' } },
})
Expand Down
Loading