Skip to content

fix(downgrader): keep only the $ref of a nullable reference to a nullable anyOf - #203

Merged
dinwwwh merged 1 commit into
mainfrom
claude/project-thread-3272xh
Oct 29, 2026
Merged

dinwwwh merged 1 commit into
mainfrom
claude/project-thread-3272xh

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 29, 2026

Copy link
Copy Markdown
Member

Requested by dinwwwh · project thread

What

#113 writes anyOf [{ $ref: T }, { type: "null" }] as the bare $ref when nothing else sits beside it and T already admits null. Its check, acceptsNull(target), only sees a type that 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 an anyOf in 3.0, and null matches both branches, so it can't become a oneOf either. NSwag reads that anyOf as an empty class:

Source 3.1 3.0 on main 3.0 here
oRPC + Zod N.nullable() / N.nullish(), N nullable with an id anyOf [$ref N, null] anyOf [$ref N, onlyNull] $ref N
Pydantic Optional[Metadata] (Metadata is anyOf [X, null]) same same, NSwag: empty Metadata2 $ref Metadata
OpenAI RealtimeSession.prompt same same $ref Prompt
$ref JsonValue with JsonValue: {} same same $ref JsonValue

N itself becomes nullable: true (or keeps its null pair) in 3.0, so the bare $ref still admits null.

How

A new admitsNull(target) replaces acceptsNull(target) in that branch. It is true for a target that acceptsNull, or one whose keywords are all annotations except an anyOf with a branch that acceptsNull, 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 title beside it, {} and a description-only target become the bare $ref; an anyOf with no null branch, an enum beside the target's anyOf, and a description beside the pair keep the anyOf. pnpm run check and pnpm 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 $ref to itself, N: anyOf [$ref N, null], now becomes N: $ref N. That schema matches only null in 3.1 and no library writes it, so it's left alone.


Generated by Claude Code

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

codecov Bot commented Oct 29, 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 29, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning

⚠️ 1 benchmark is dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 29 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/project-thread-3272xh (52ca954) with main (8a0d645)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@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

  • admitsNull(target) replaces acceptsNull(target) — packages/downgrader/src/v3.1-to-v3.0.ts:795, used at :946. Widens the nullable-$ref collapse to targets that admit null via annotations + an anyOf with an acceptsNull branch (Zod/Pydantic nullable form), or via annotations only (e.g. {}).
  • Comment updated at :947 to 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, enum beside the target's anyOf, description beside the outer pair) correctly keep the anyOf.
  • 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.

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

@dinwwwh
dinwwwh merged commit a16a798 into main Oct 29, 2026
11 checks passed
dinwwwh added a commit that referenced this pull request Oct 29, 2026
…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]>
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