Skip to content

fix(downgrader): read a tag property through a $ref when keeping a oneOf - #206

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

dinwwwh merged 1 commit into
mainfrom
claude/project-thread-35fr1c

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 29, 2026

Copy link
Copy Markdown
Member

Requested by dinwwwh · project thread

When a oneOf branch loses a restriction in 3.0, such as a tuple, the oneOf becomes an anyOf unless isDisjoint (#139) proves no value matches two branches. It reads each branch's tag property with tagsOf, which took const/enum only from an inline property. A tag written as a $ref to a one-value enum or const held nothing, so the union became an anyOf although the tags tell the branches apart. partTags already followed a $ref for a branch and its allOf members, just not for a property.

Change

  • follow (the $ref chain walker that was local to partTags, with its recursion-cut check) is now a top-level function.
  • tagsOf takes ctx and follows a property that is a $ref with only annotations beside it (isAnnotatedRef, as Zod writes a described $ref). A $ref with other siblings, such as a type naming null that becomes nullable, is still not read.
  • README row for oneOf with a loosened branch: the const/enum may come through a $ref with only annotations beside it.

Where it shows up

  • oRPC with Zod 4.6.7: z.discriminatedUnion('kind', [...]) whose tag is z.literal('a').meta({ id }) or z.enum(['a']).meta({ id }) gives kind: { $ref }. With one branch holding a z.tuple, main writes anyOf; this keeps oneOf.
  • Entitle API: ApprovalFlowStepResponseSchema.approvalEntities.items and three similar unions of 4 refs, each tagged by a $ref to its own one-value enum, become oneOf again. NSwag 14.7 then writes the first branch's class instead of an empty class.

Hunt checks on the patched build: a 19,775-doc corpus diff changes only Entitle's 4 unions (8 sites), no throws; the Entitle output stays valid 3.0; the value oracle loses 0 of 22,108 values.

Tests

New test reads the values of a property through a $ref beside only annotations (fails on main): bare $ref to a const and to a one-value enum, an annotated $ref, and a chain through an alias keep oneOf; a $ref beside type: ['string', 'null'] and a $ref to a nullable enum still give anyOf. 365 tests pass, coverage stays 100%, pnpm run check passes.


Generated by Claude Code

A oneOf with a branch that loosens in 3.0 stays a oneOf only when the
branches are apart, which the check reads from each branch's const or enum
properties. A property written as a $ref to a one-value enum or const, as
Zod writes z.literal('a').meta({ id }) and the Entitle API each tag, held
nothing, so the union became an anyOf. The check now follows a $ref with
only annotations beside it, as it already does for a branch and its allOf
members.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVaw7RoZZwQ34LdS1QdzFN
@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-35fr1c (04dffc9) with main (8ed8c16)

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

  • tagsOf follows a property $ref — when a tag property is a $ref beside only annotations (isAnnotatedRef), it is resolved via follow so its target's const/enum values contribute to the disjointness check.
  • follow extracted to top level — the $ref-chain walker (with its cycle / in-progress-inline cut) moved out of partTags verbatim and now takes ctx explicitly.
  • README row updated — the oneOf-with-a-loosened-branch row notes the const/enum may now come through a $ref with only annotations beside it.
  • New test — covers bare $ref to const/one-value enum, an annotated $ref, and a chain through an alias (all keep oneOf), plus a $ref beside type: ["string","null"] and one to a nullable enum (both stay anyOf).

The change is conservative: follow reads the final target's const/enum, which is always a superset of the effective values across a $ref chain, so it can only suppress a oneOf, never create one falsely. Enum/const are preserved by the 3.0 conversion and kept $refs still resolve in the output. Verified locally: the new test passes, all 288 downgrader tests pass, and pnpm run check is green.

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

@dinwwwh
dinwwwh merged commit 5855eb0 into main Oct 29, 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