Repository navigation
fix(downgrader): make an anyOf of a null-rejecting branch and null a oneOf in 3.1 to 3.0 - #88
Conversation
…oneOf in 3.1 to 3.0 A `$ref` or typed `enum` beside the null branch, as Zod `.nullable()` and pydantic `Optional[Model]` write, stays as an `anyOf` pair after #87. NSwag reads that pair as an empty class, but a `oneOf` as the nullable type. Null matches only the null branch and a branch with a type and no `nullable` never matches null, so the `oneOf` means the same. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ly7PKnnYtmt4xpZpzZuWp
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…luding a $ref Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ly7PKnnYtmt4xpZpzZuWp
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
anyOf+null→oneOf:mergeNullablenow rewrites the pair to aoneOfwhen the non-null branch rejects null, so NSwag 14.7 reads it as nullable instead of an empty class.rejectsNullhelper: identifies a branch that rejects null by checking for a non-nulltypeand nonullable: true;typeis conjunctive in JSON Schema, so this holds even when a siblingenum/constcontains null.NULL_REJECTING_REFStracking:convertSchemaRefrecords each kept{$ref}whose 3.1 target rejects null, checked against the source target since a cycle can meet its conversion unfinished.- Tests + README: typed
enum/allOf/notbranches and the keyword-clash case now expectoneOf; a new$reftest covers atype: objecttarget (becomesoneOf) versus["object","null"]/ 3.0-stylenullable(stayanyOf). Two oRPC snapshot positions updated.
I verified the oneOf rewrite is acceptance-preserving: onlyNull() matches exactly null, and the recorded/predicted branch never matches null, so the two branches are disjoint and oneOf ≡ anyOf. A specialist pass enumerated 225k+ direct schemas and 230k+ ref cases against a 3.0 evaluator with no counterexample. Full suite (256 tests) and uncheck (oxlint + oxfmt + tsc) pass.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- README row clarified (delta since prior review):
packages/downgrader/README.mdrow 183 now reads "{ type: "null" }and one other branch, as Zod writes a nullable object, array, integer, enum, or$ref", and the 3.0 side now qualifies thenullable: truemerge with "when it has a singletype". No code change; the wording matchesmergeNullable/rejectsNullbehavior and the$ref-kept-as-written case.
No new behavioral surface in this commit — the production change in 05bf13f was already approved in the prior review (review #5637779763).
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…pair in 3.1 to 3.0 #88 resolved and checked the target of every kept schema `$ref`, though only a `$ref` beside the null branch of a two-branch `anyOf` reads the result. `mergeNullable` now looks up that target in the input itself, which is always complete, so the cycle concern goes away too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ly7PKnnYtmt4xpZpzZuWp
…pair in 3.1 to 3.0 (#89) <!-- claude-projects-attribution: {"github_login":"dinwwwh"} --> _Requested by **dinwwwh** · [project thread](https://claude.ai/code/project/chan_01UcKMz91tMarPV1kWLVgLYE?thread=cmsg_01UcKMz91tMarPV1kWLVgLYEFygEMePkCPrmjzGyxpMkUe)_ #88 made 3.1 to 3.0 about 6 to 7% slower on `$ref`-heavy docs. Wall time on the bench doc went from 15.1 to 16.1 ms, and on Discord from 19.1 to 20.3 ms. CodSpeed's simulation mode doesn't catch it. The cause: `convertSchemaRef` resolved and checked the target of every kept schema `$ref` and added it to a `WeakSet`. Only a `$ref` beside the null branch of a two-branch `anyOf` ever reads that result. This PR moves the check into `mergeNullable`, so it runs only for those pairs: - `shared.ts` exports `find`, the uncached pointer lookup that `resolvePointer` already wraps. - `NULL_REJECTING_REFS` and the check in `convertSchemaRef` are removed. - `downgradeV30` passes `root` to `mergeNullable`. When the other branch is a kept `$ref`, `mergeNullable` checks the target in the input with `rejectsNull(find(root, ref))`. The input is always complete, so a cycle can't hand it a half-converted target. Output doesn't change. All 256 tests and both snapshots (oRPC and corpus) pass with no edits. A separate check found byte-identical output to #88 on 41 real docs and 5,403 fuzz cases, with speed back to the pre-#88 level (15.4 ms and 19.0 ms). Locally, `vitest bench` puts this branch ahead of main on "generated api, 100 resources" in both runs. `pnpm run check` passes. --- _Generated by [Claude Code](https://claude.ai/code/session_012Ly7PKnnYtmt4xpZpzZuWp)_ Co-authored-by: Claude <noreply@anthropic.com>
…3.0 nullable merge (#90) <!-- claude-projects-attribution: {"github_login":"dinwwwh"} --> _Requested by **dinwwwh** · [project thread](https://claude.ai/code/project/chan_01UcKMz91tMarPV1kWLVgLYE?thread=cmsg_01UcKMz91tMarPV1kWLVgLYE6GXWVbDpgbhjLdZd5YRnn1)_ Test-only. Stryker on main left mutants alive in the checks #87/#88 added to the 3.1 to 3.0 nullable merge. This adds expectations to the existing tests in `tests/v3.1-to-v3.0.test.ts`; main already gives the right output for each one. - `anyOf` of a `$ref` to a `type: "null"` target and null stays `anyOf`. This covers `rejectsNull` letting `"null"` through. - `anyOf` of a `$ref` to a `type: ["object", "string"]` target and null becomes `oneOf`. This covers the array `type` case of `rejectsNull`. - A typed branch with `oneOf` beside null is not merged and becomes a `oneOf` pair. This covers `oneOf` in `NOT_NULLABLE_KEYWORDS`. - A null branch with a `description` is not treated as only null, so the `anyOf` stays as it is. This covers the key count in `isOnlyNullSchema`. I applied each of those four mutations by hand. All of them pass the tests on main and fail with this change. --- _Generated by [Claude Code](https://claude.ai/code/session_01KnRgKQZ6Maz5HiVMYr9twz)_ Co-authored-by: Claude <noreply@anthropic.com>

Requested by dinwwwh · project thread
After #87, an
anyOfof null and a branch that can't takenullable: truestays ananyOfpair in 3.0. That covers a$ref(ZodRef.nullable(), pydanticOptional[Model], oRPC's self-referencingCategory.parent) and a typedenum(z.enum([...]).nullable()). NSwag 14.7 reads that pair as an empty class, so dotnet clients fail at runtime. It reads aoneOfas the nullable type and round-trips.The
oneOfmeans the same thing here. Null matches only the null branch. A branch with atypeand nonullable: truenever matches null. So at most one branch matches any value.rejectsNull(schema): a record with nonullable: truewhosetypeis a string other than"null", or a non-empty array of strings without"null".convertSchemaRefrecords each$refit keeps as written when its target rejects null. It checks the 3.1 target, because in a cycle the converted target may not be finished yet.mergeNullable: when the merge doesn't apply,oneOfis unset, and the other branch is a recorded$refor rejects null, theanyOfbecomes aoneOf.Other generators the issue hunt checked (openapi-typescript, hey-api, oapi-codegen, datamodel-codegen, openapi-python-client, Kiota, openapi-generator typescript-fetch) produce the same code either way.
Tests: the typed
enum/allOf/notbranches and the keyword-clash case now expectoneOf. A dangling$ref, a branch withnullable, and an untyped branch stayanyOf. So does a pair beside an existingoneOf. A new test covers a$refto a component typedobject(becomesoneOf), and to components typed["object", "null"]or carrying a 3.0-stylenullable(stayanyOf). The oRPC snapshot changes in two places:Category.parentand thePlanet | nullresponse. README row updated. The 3.1 to 3.0 bench shows no change beyond noise.Generated by Claude Code