Repository navigation
refactor(downgrader): replace any $ref into items or additionalProperties with its target - #320
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
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.
ℹ️ Minor suggestion only — the simplification itself looks sound.
Reviewed changes
isInlinedbroadened —packages/downgrader/src/v3.1-to-v3.0.ts: replacesisIntoReplaced's per-segment pointer walk withINTO_REPLACED = /\/(?:items|additionalProperties)(?:\/|$)/, so any resolvable$refwith an/itemsor/additionalPropertiespath segment is now inlined rather than kept.- README note relocated —
packages/downgrader/README.md: the "$refintoitems/additionalPropertiesis replaced by its target" line moves from theprefixItems/patternProperties/propertyNamesrows to the$defsrow. - Tests updated —
packages/downgrader/tests/v3.1-to-v3.0.test.ts: a$refinto a plain array'sitemsnow expects inlining, and a plain-recordadditionalPropertiesinline case is added.
I verified the simplification is value-preserving for the intended cases and that the prefix-collision guard (itemsLeft) works. The one edge I'd call out is the exact-name match, detailed inline.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Requested by dinwwwh · project thread
What
Simplifies #312, following the request for the simplest approach with minimal logic.
#312 made a
$refinto a tuple'sitems, or into anadditionalPropertiesbesidepatternPropertiesor of an enum-keyed record, be replaced by its target, since the conversion puts another schema there and a$refkept as written would read that one. To tell those apart from otheritemsandadditionalProperties,isIntoReplacedwalked every matching segment of the pointer and resolved and inspected each holder.Now every
$refwith an/itemsor/additionalPropertiessegment is replaced by its target, with one regex test inisInlined. Replacing a$refby its converted target accepts the same values, so nothing gets stricter.README: the note moves from the
prefixItems,patternPropertiesand enum-keyedpropertyNamesrows to the$defsrow, which already says how a replaced$refconverts.Tests
The #312 test now also expects a
$refinto theitemsof a plain array to be replaced, and still expects one into a property nameditemsLeftto stay. The enum-record test expects a$refinto a plain record'sadditionalPropertiesto be replaced too.pnpm check,pnpm testand coverage (100%) pass.Tradeoffs
$refinto theitemsoradditionalPropertiesof any schema now comes out as a copy of its target instead of a$ref. Generators such as zod-to-json-schema point a reused schema at its first use this way, so a code generator may now see an inline schema where it saw a shared one. Semantics are unchanged.Generated by Claude Code