Skip to content

fix(downgrader): make an anyOf of a null-rejecting branch and null a oneOf in 3.1 to 3.0 - #88

Merged
dinwwwh merged 2 commits into
mainfrom
claude/project-thread-o6uyg9
Oct 24, 2026
Merged

dinwwwh merged 2 commits into
mainfrom
claude/project-thread-o6uyg9

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 24, 2026

Copy link
Copy Markdown
Member

Requested by dinwwwh · project thread

After #87, an anyOf of null and a branch that can't take nullable: true stays an anyOf pair in 3.0. That covers a $ref (Zod Ref.nullable(), pydantic Optional[Model], oRPC's self-referencing Category.parent) and a typed enum (z.enum([...]).nullable()). NSwag 14.7 reads that pair as an empty class, so dotnet clients fail at runtime. It reads a oneOf as the nullable type and round-trips.

The oneOf means the same thing here. Null matches only the null branch. A branch with a type and no nullable: true never matches null. So at most one branch matches any value.

  • rejectsNull(schema): a record with no nullable: true whose type is a string other than "null", or a non-empty array of strings without "null".
  • convertSchemaRef records each $ref it 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, oneOf is unset, and the other branch is a recorded $ref or rejects null, the anyOf becomes a oneOf.

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/not branches and the keyword-clash case now expect oneOf. A dangling $ref, a branch with nullable, and an untyped branch stay anyOf. So does a pair beside an existing oneOf. A new test covers a $ref to a component typed object (becomes oneOf), and to components typed ["object", "null"] or carrying a 3.0-style nullable (stay anyOf). The oRPC snapshot changes in two places: Category.parent and the Planet | null response. README row updated. The 3.1 to 3.0 bench shows no change beyond noise.


Generated by Claude Code

…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
@dinwwwh dinwwwh self-assigned this Oct 24, 2026
@codecov

codecov Bot commented Oct 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 21 untouched benchmarks


Comparing claude/project-thread-o6uyg9 (4fb6fc0) with main (30a428b)

Open in CodSpeed

…luding a $ref

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Ly7PKnnYtmt4xpZpzZuWp

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • anyOf + null → oneOf: mergeNullable now rewrites the pair to a oneOf when the non-null branch rejects null, so NSwag 14.7 reads it as nullable instead of an empty class.
  • rejectsNull helper: identifies a branch that rejects null by checking for a non-null type and no nullable: true; type is conjunctive in JSON Schema, so this holds even when a sibling enum/const contains null.
  • NULL_REJECTING_REFS tracking: convertSchemaRef records 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/not branches and the keyword-clash case now expect oneOf; a new $ref test covers a type: object target (becomes oneOf) versus ["object","null"] / 3.0-style nullable (stay anyOf). 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.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • README row clarified (delta since prior review): packages/downgrader/README.md row 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 the nullable: true merge with "when it has a single type". No code change; the wording matches mergeNullable/rejectsNull behavior 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).

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@dinwwwh
dinwwwh merged commit 0321f9d into main Oct 24, 2026
10 checks passed
dinwwwh pushed a commit that referenced this pull request Oct 24, 2026
…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
dinwwwh added a commit that referenced this pull request Oct 24, 2026
…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>
dinwwwh added a commit that referenced this pull request Oct 24, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants