Skip to content

fix(downgrader): drop an anyOf branch equal to the one before it in 3.0 - #148

Merged
dinwwwh merged 1 commit into
mainfrom
claude/project-thread-02j0xo
Oct 27, 2026
Merged

dinwwwh merged 1 commit into
mainfrom
claude/project-thread-02j0xo

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 27, 2026

Copy link
Copy Markdown
Member

Requested by dinwwwh · project thread

Problem

oRPC 1.16.1 (npm latest) with its Zod 4 converter writes z.file().mime(['application/pdf', 'image/png']) as one branch per media type:

{ "anyOf": [{ "type": "string", "contentMediaType": "application/pdf" }, { "type": "string", "contentMediaType": "image/png" }] }

Each branch becomes { type: "string", format: "binary" }, so 3.0 got an anyOf of identical branches, and .nullable() stayed anyOf [anyOf [binary, binary], null branch]. NSwag (C# and TS) then types the field as a generated class instead of FileParameter, and oapi-codegen as a union wrapper instead of openapi_types.File. The flat { type: "string", format: "binary" } (with nullable: true) gives proper uploads in both.

Change

In the deferred union pass, before mergeEnums/mergeNullable/scalarOneOf, an anyOf branch equal to the one before it is removed. That is exact for anyOf. When one branch is left, it takes the place of the anyOf if the schema around it holds only annotations that the branch lacks or repeats (or nothing at all beside a $ref, whose siblings 3.0 ignores); otherwise it stays as a one-branch anyOf. A nullable file then merges as a pair, as before. oneOf is left alone, since dropping a branch there would widen it.

Every oRPC 1.16.1 shape now comes out as a binary string: plain, .describe(), .nullable(), .nullable().describe(), .optional(), .nullish(), and z.array(...) items.

Two design choices, both from measurements:

  • Shallow comparison. Branches are equal when they hold the same keys with the same values, and an object value matches only itself. A first draft compared JSON.stringify of each branch, which blew up on dereferenced documents that share objects: a nullable object diamond took 2.2 s at depth 20 (2 ms on main) and hung at depth 64. Now depth 64 takes 3 ms.
  • Neighbors only. Comparing each branch with the one before it keeps the cost linear. Comparing all pairs cost +4.7% instructions on OpenAI's spec (13k comparisons, mostly from its 46 to 64 branch event unions) and +2.0% on the 100-resource bench doc. oRPC writes the equal branches in a row.

README: a row for the new behavior, plus the queued docs clause on the older-drafts limitation that oRPC 1.x's Valibot converter writes draft-07 unless given { target: 'draft-2020-12' }.

Tests

  • New test covers the oRPC shapes, the nullable merge, the branches left still merging (a oneOf of scalars), non-neighbors kept, $ref alone vs. beside a description, a non-annotation sibling, conflicting vs. repeated annotations, non-record branches, and oneOf kept.
  • Three existing expectations change where the output now drops an equal neighbor (all exact): anyOf [string, string] leaves the scalar-union "kept" list; a loosened oneOf test gets a distinguishing minProperties; a loose tuple's items collapse to one schema.
  • Coverage stays at 100% statements and branches. Corpus and oRPC snapshots are unchanged.

Verification

  • Duplicate-branch meaning fuzz (random 3.1 unions that repeat branches, nested, with siblings; draft-04 check of 3.0 outputs on sample values): 15,000 docs over 5 seeds, 0 values where main's 3.0 and this branch's 3.0 disagree. As a check of the harness, a planted variant that also deduplicates oneOf disagrees on 373 of 3,000.
  • OpenAI and Discord 3.0 outputs are byte-identical to main.
  • Perf, callgrind with CodSpeed's V8 flags: 100-resource bench doc +1.0%, OpenAI +0.9%. Wall time on the 100-resource doc, the corpus, Discord and OpenAI is within twin-build noise (about ±3%).

Generated by Claude Code

oRPC 1.x's Zod 4 converter writes `z.file().mime([...])` as an anyOf of
strings with one media type each, which all become the same binary string
in 3.0. NSwag and oapi-codegen then read the field as a generated class or a
union instead of a file upload, and a nullable one stays a nested anyOf with
the null branch.

A branch equal to the one before it (same keys, same values, objects only
by identity) is now removed, and a lone branch left takes the place of the
anyOf when only annotations sit beside it, so the null pair merge applies.
oneOf keeps its branches. Also notes in the README that oRPC 1.x's Valibot
converter writes draft-07 by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7RsELoLWzSRAFMoTSS
@dinwwwh dinwwwh self-assigned this Oct 27, 2026
@codecov

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

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 21 untouched benchmarks


Comparing claude/project-thread-02j0xo (d8c193f) with main (5b91477)

Open in CodSpeed

@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

  • dropEqualBranches in the deferred union pass (packages/downgrader/src/v3.1-to-v3.0.ts:758) — drops an anyOf branch shallow-equal to its immediate predecessor (isSameBranch, === per key, object values by identity), and hoists a lone remaining branch over the anyOf when the surrounding schema holds only annotations the branch lacks/repeats, or nothing beside a $ref whose 3.0 siblings are ignored. oneOf is untouched.
  • Deferred-pass gate (:1357) — mergeEnums/mergeNullable/scalarOneOf now run for an anyOf only while more than one branch remains; a union reduced to a single branch is left as-is. This also avoids mergeNullable wrongly adding nullable: true to a lone non-null branch.
  • Tests — new case covering the oRPC file shapes, the nullable merge, non-neighbor branches, $ref handling, annotation conflicts, non-record branches, and oneOf; three existing expectations adjusted where dedup now collapses branches.
  • README — new table row for the equal-branch behavior, plus the oRPC Valibot draft-07 note on the older-drafts limitation.

Verified locally: vitest run passes (284 tests, corpus and oRPC snapshots unchanged) and uncheck is clean (oxlint/oxfmt/tsc) on both changed source/test files. The equivalence reasoning holds — duplicate-anyOf removal is exact, and the hoist only proceeds when the surrounding keywords are non-assertive or preserved.

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

@dinwwwh
dinwwwh merged commit 3676052 into main Oct 27, 2026
11 checks passed
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