Repository navigation
fix(downgrader): write {} for a 3.2 content $ref that does not resolve - #326
Conversation
… no oneOf branch apart, and takes null
Where several $refs enter the same cycle, the first copy made is reused, so
a later one can hold a $ref that was cut to {} while another target was
being inlined. isDisjoint and the nullable pair merge read such a $ref in
the input, saw its target's type or tag, and kept a oneOf that the {} then
let a value match twice. The holders of cut $refs are now recorded, and
follow() and throughRef() read them as keeping nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…operties beside other properties, out of enum merging A oneOf rejects a value two of its branches share, so mergeEnums keeps a oneOf whose literals repeat one. It compared them serialized, which tells equal objects with their keys in another order apart, so it merged such a oneOf into an enum that took the shared value. A oneOf of object or array literals now stays as it is. And an additionalProperties in a literal branch counted as repeating the one around it although that one sits beside other properties, which it then let through, so it repeats only beside the same properties. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…side annotations enumNames read the names at the end of the propertyNames $ref chain, past a $ref with an enum, maxLength or other keyword beside it that narrows the names further. The record then got a property for each name of the last enum, allowing keys the input rejects, and counted as converted exactly, so a not or oneOf over it stayed. The chain now stops at such a $ref, so the record loses its key names and counts as loose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…iction around an alias component or a cycle of objects #309 converts the input again when a not or oneOf read a $ref's target before that target lost a restriction around a cycle. It missed two cycles. A component that is only a $ref, an alias, has no output of its own, so the output recorded for it was undefined; it now records the output of the target its chain of kept $refs ends at. And a dereferenced document closes a cycle with the object itself rather than a $ref; convertObject now tells convertSchema when it meets a schema again, and one that was not loose then but is once converted makes the next pass read it as loose. First visits pay nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…ed-union $refs The rerun recorded nothing for an alias whose chain passes through a $ref the conversion inlines, such as one into $defs, or that is a $ref to a tagged union beside only annotations, so a not over it around a cycle was kept over a target that lost a restriction. sharedOutput now follows every $ref convertSchema treats as an alias, and reads an inlined hop's output from its inlined copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…form A schema that loses a restriction around a cycle made the conversion run again, so a not or oneOf that read it too early could drop or widen. Only such a choice changes when more schemas read as loose, so a pass that kept no not, and no oneOf without a loose branch, needs no other. A document with a recursive schema that loses a restriction and no not or oneOf now converts in one pass, about half the time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…y property as loose OAS 3.0 applies a required readOnly property to responses only and a required writeOnly property to requests only, while 3.1 requires it both ways. The 3.0 output so loses that restriction, so a not or oneOf over such an object must not keep it, and the property tells no oneOf branch apart. The property is read through a chain of $refs too, as 3.0 reads each target in its place. Such a schema in a cycle, as a recursive DTO with a required readOnly id, makes a document that keeps a not or oneOf convert twice, which the README performance note now says. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
… or a set per name CodSpeed read the order schema bench 19% slower with the check. It now loops over `required` once per schema, reads each property's fields directly, and makes a visited set only past the first `$ref`. Instructions per conversion of that schema drop from 4% over main to 2% under callgrind with CodSpeed's V8 flags. A cycle of `$ref`s, which never reaches a schema, is tested too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
Marking the schema itself loose lets a $ref to it, and every object around it, read as loose through the existing checks, so no walk over required names is needed. Only the tag check still reads through $refs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…stead of converting again A $ref or object cycle that reaches a schema before its conversion is done cannot know yet what that schema loses, so it now counts as loose. This replaces the second pass (#309, #316) and its skip (#317) with two checks, at the cost of a recursive schema always counting as loose: a not over it is removed, and a oneOf with it becomes anyOf unless its branches are disjoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…ties with its target Checking which items or additionalProperties the conversion replaces needed a walk over the pointer and a lookup of each holder. Replacing every $ref into either keeps the same values accepted, with one regex test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
A target that is a $ref again now holds no names, so the record loses its key names, as it does for any other propertyNames it cannot read. This replaces the walk along a chain of annotated $refs and its visited set, which every schema allocated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
find() percent-decodes a $ref, but the checks for $defs, the removed parts, and items or additionalProperties read it as written. So a $ref such as #/components/schemas/Pair/%69tems was kept, and read the looser schema the conversion puts there, which a not over it reads as stricter. Any $ref with a % is now replaced by its target, which needs no decoding. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
… target Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…hat sets style, explode, or allowReserved 3.1 ignores contentType, also its default, where style, explode, or allowReserved is set, while 3.0 ignores those in multipart and applies contentType, so a part 3.1 accepts in any type was held to one type in 3.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…t a component name 3.1 names a scheme in a security requirement only by its component name, where 3.2 also takes a URI reference to one. Kept as written, such a name named no scheme in 3.1, so no request could meet the requirement. Any name that is not a component name is now left out, which asks for less. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…as written
3.2 sends the value of style: cookie, or of allowReserved in a path or
cookie, as written, where the form or simple style 3.1 falls back to
percent-decodes it, so the schema would read another string. Such a
parameter's schema now becomes {}.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
3.1 allows no $ref in a content map, and one that did not resolve, such as
an external one, was removed, which took no payload of that media type. It
now becomes {}, which takes any payload.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Unresolved
content$ref→{}—convertMediaTypeEntrynow maps aninline()DROP(missing component, external file,$self-relative, or self/mutual loop) to{}, so the media type stays with no schema (accepts any payload) instead of being deleted. The old deletion narrowed the document to accept no body of that media type, a regression against the downgrader's "loses detail, never meaning" contract;{}widens only, which is the safe direction. - README — the external-
$refparagraph and the 3.2→3.1 Limitations bullet now describe the{}outcome rather than removal. - Test — the existing case now asserts
{}and adds an external-ref (text/csv) case alongside the missing and self-looping ones.
The change is correctly scoped: inline() returns DROP only for an unresolvable target, convertMediaType (convertObject) never returns DROP, and convertMediaTypeEntry now never returns DROP, so the map wrapper only stops dropping the key for exactly the intended case. pnpm vitest run packages/downgrader passes (302), matching the author's claim.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
Merging this PR will not alter performance1 warning
Comparing Footnotes
|

Requested by dinwwwh · project thread
Summary
3.1 allows no
$refin acontentmap, so the 3.2 → 3.1 downgrade replaces each one with its target. When the$refdoes not resolve (an external file, a$self-relative one, a missing component, or one that loops back on itself), the entry was removed. Removing a media type from a request body means the 3.1 document takes no payload of that type, which rejects bodies the 3.2 document accepts.Such an entry now becomes
{}, a media type with no schema, which takes any payload. The change is one line inconvertMediaTypeEntry, plus the README intro and limitation updated to match.Tradeoffs
$refs incontent, so its output is unchanged.Test plan
{}for a missing, a self-looping, and an externalcontent$ref.vitest run packages/downgrader(302 passed) andpnpm checkpass locally.Generated by Claude Code