Repository navigation
fix(downgrader): keep only the $ref of a nullable reference to a nullable anyOf - #203
Conversation
…able anyOf #113 writes `anyOf [$ref T, null]` as the bare `$ref` when T already admits null, but only saw a `type` that names "null", as GitHub writes. Zod, Pydantic and OpenAI write a nullable schema as `anyOf [X, null]`, so Zod's `N.nullable()` on a nullable `N` with an id, or Pydantic's `Optional[Metadata]`, stayed an `anyOf`, which NSwag reads as an empty class. A target that is such an `anyOf` with only annotations beside it, or only annotations such as `{}`, now counts too. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVaw7T1v1Kc7Y9khGjM2QR
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
admitsNull(target)replacesacceptsNull(target)—packages/downgrader/src/v3.1-to-v3.0.ts:795, used at:946. Widens the nullable-$refcollapse to targets that admit null via annotations + ananyOfwith anacceptsNullbranch (Zod/Pydantic nullable form), or via annotations only (e.g.{}).- Comment updated at
:947to name the new Zod/Pydantic case; the old comment only mentioned the GitHub form. - Test added —
packages/downgrader/tests/v3.1-to-v3.0.test.ts:1129: the Zod/Pydantic nullable target collapses to the bare$ref, while three negative cases (no null branch,enumbeside the target'sanyOf,descriptionbeside the outer pair) correctly keep theanyOf. - README null-pair row updated to describe the new target shapes; the rest of the README diff is oxfmt re-padding.
The collapse is sound only if the target still accepts null after conversion, since the outer Owner already accepts null through its own branch. I verified this independently with Ajv 2020-12 differentials across ~2400 generated targets that pass admitsNull: every one produced a converted target that still accepts null, with no unsound collapse. Full suite (364) and uncheck (oxlint/oxfmt/tsc) pass on the changed files.
The disclosed self-referential edge (N: anyOf [$ref N, null] → N: $ref N) is pathological, absent from real docs, and called out in the PR — not worth special handling.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…nd nullable targets (#205) <!-- claude-projects-attribution: {"github_login":"dinwwwh"} --> _Requested by **dinwwwh** · [project thread](https://claude.ai/code/project/chan_01UcKMz91tMarPV1kWLVgLYE?thread=cmsg_01UcKMz91tMarPV1kWLVgLYE7TKScycrTLZfzZMNY1F8sa)_ Three spots in the 3.1 to 3.0 converter check 3.1 input keys for annotations, and two of them used a shorter list than "every annotation": 1. **Records keyed by an enum** (`enumNames`, from #100) accepted only `enum`, `const`, `title`, `description`, and `type: "string"` in the key schema. Any other annotation, such as `example`, `examples`, `default`, `deprecated`, `$comment` or an `x-` key, made it bail, so the 3.0 record allowed any key. - Meilisearch 1.15.2 (utoipa): `BatchStats.status` and `BatchStats.types` are keyed by an enum with `example: "processing"`. openapi-typescript gave `[key: string]: number`. With this change it gives `enqueued?: number; processing?: number; ...`. - ElevenLabs (Pydantic): `ConversationInitiationSource` has `default: "unknown"` beside its 27-value enum. The records keyed by it now get 27 properties, and the discriminated `oneOf` responses that hold them keep their `oneOf` instead of becoming `anyOf`. - oRPC with Zod: `z.record(E, V)` where `E` has `.meta({ id, examples })` or `.meta({ id, deprecated: true })` now gets one property per name, as a described `E` already did. 2. **A nullable `$ref` to a nullable `anyOf`** (`admitsNull`, from #203) allowed only 3.0 annotations beside the target's `anyOf`, so the `examples` that Zod's `.meta` writes there kept the `anyOf`. NSwag reads that as an empty class. `isAnnotatedRef` (#204) already had the longer list. A new `isInputAnnotation` helper (`examples`, `$comment`, or a 3.0 annotation) now serves all three. The README row for enum-keyed records now says that annotations beside the names are dropped. The nullable-reference row already said "only annotations", so it is unchanged. Evidence from the issue hunt: on a 19,775-doc corpus the `enumNames` change touches 7 docs at 64 sites, with no throws and no values lost per the value oracle. The `admitsNull` change touches none of those docs, but it fixes the oRPC repro. ### Tests - `pnpm run check` and `pnpm test` pass (364 tests). Downgrader coverage stays at 100%. - The new assertions fail on main: the enum-key test, the `$ref`-to-enum test, and the nullable `anyOf` target test. --- _Generated by [Claude Code](https://claude.ai/code/session_01VVaw7SWqGqen9de9vgJcko)_ Co-authored-by: Claude <[email protected]>

Requested by dinwwwh · project thread
What
#113 writes
anyOf [{ $ref: T }, { type: "null" }]as the bare$refwhen nothing else sits beside it andTalready admits null. Its check,acceptsNull(target), only sees atypethat names"null", as GitHub writes.Zod, Pydantic and OpenAI write a nullable schema as
anyOf [X, { type: "null" }]instead. Another nullable use of it then stays ananyOfin 3.0, and null matches both branches, so it can't become aoneOfeither. NSwag reads thatanyOfas an empty class:N.nullable()/N.nullish(),Nnullable with an idanyOf [$ref N, null]anyOf [$ref N, onlyNull]$ref NOptional[Metadata](MetadataisanyOf [X, null])Metadata2$ref MetadataRealtimeSession.prompt$ref Prompt$ref JsonValuewithJsonValue: {}$ref JsonValueNitself becomesnullable: true(or keeps its null pair) in 3.0, so the bare$refstill admits null.How
A new
admitsNull(target)replacesacceptsNull(target)in that branch. It is true for a target thatacceptsNull, or one whose keywords are all annotations except ananyOfwith a branch thatacceptsNull, or one with only annotations, such as{}. README row on the null pair says so; the rest of the README diff is oxfmt re-padding the table.Tests
New test "keeps only the $ref of an anyOf with null when the target is a nullable anyOf, as Zod and Pydantic write it" (fails on main): the Zod form, with a Pydantic
titlebeside it,{}and a description-only target become the bare$ref; ananyOfwith no null branch, anenumbeside the target'sanyOf, and adescriptionbeside the pair keep theanyOf.pnpm run checkandpnpm test(364) pass, downgrader coverage 100%.Corpus check from the issue hunt (19,775 docs): 32 docs change at 77 sites, every one this pair becoming the bare
$ref; validator and value-oracle results match main.Known edge: a component that is a nullable
$refto itself,N: anyOf [$ref N, null], now becomesN: $ref N. That schema matches only null in 3.1 and no library writes it, so it's left alone.Generated by Claude Code