Repository navigation
fix(downgrader): write schema {} for a 3.2 path or cookie value sent as written - #325
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
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will not alter performance1 warning
Comparing Footnotes
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
finishParameterwidens the schema for parameters 3.2 writes as written (packages/downgrader/src/v3.2-to-v3.1.ts:246): a parameter besideschema(notcontent) whosestyleis"cookie", or whoseallowReservedistruewithin: "path"/"cookie", now converts toschema: {}. 3.1 drops that style and falls back toform/simple, which percent-decode, so the original constraint could reject a value 3.2 accepts. Headers are correctly left alone.- Test coverage (
packages/downgrader/tests/v3.2-to-v3.1.test.ts:233) pins the cookie-style, cookie/path-allowReserved, and header-keeps-schema cases. - README gains one row documenting the
schema: {}rule and the re-decoding it compensates for.
This is a sound, conservative fix — it only widens the schema, consistent with the downgrader's "loses detail, never meaning" contract. I checked the trigger set against the version docs (v3.2.ts:540-554 vs v3.1.ts:590-591, and the cookie-style note at v3.2.ts:435-437): the cases where 3.2 writes as-written but 3.1 re-decodes are exactly {style: "cookie"} ∪ {allowReserved on path/cookie}, and query/headermatch across versions, so no case is missed. Verifiedvitest run packages/downgrader(302 passed) anduncheck` on the three files, all green.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Requested by dinwwwh · project thread
Summary
3.2 sends a parameter value as written, without percent-encoding, for
style: "cookie"and forallowReserved: truein a path or cookie. The downgrade already removes both (3.1 has nocookiestyle and appliesallowReservedonly to query parameters), so 3.1 falls back toformorsimple, which percent-decode the value. A value such asa%3Dthat 3.2 hands to the schema asa%3Dthen reaches a 3.1 schema asa=, so a schema likepattern: '^[^=]*$'rejects a value the 3.2 document accepts.The fix: for such a parameter (with
schema, notcontent), the schema becomes{}. Headers are left alone, since neither version percent-encodes them. The README gets one table row.Tradeoffs
style: "cookie"orallowReservedoutside query, which oRPC does not write, so oRPC output is unchanged.Test plan
style: "cookie", cookie and path withallowReserved, and a header withallowReservedthat keeps its schema.vitest run packages/downgrader(302 passed) andpnpm checkpass locally.Generated by Claude Code