Skip to content

feat(downgrader): turn a propertyNames enum into one property per name in 3.1 to 3.0 - #100

Open
dinwwwh wants to merge 3 commits into
mainfrom
claude/project-thread-d4r2o8
Open

dinwwwh wants to merge 3 commits into
mainfrom
claude/project-thread-d4r2o8

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Requested by dinwwwh · project thread

Zod z.record(z.enum(['a', 'b']), V) and z.partialRecord, and Valibot v.record(v.picklist([...]), V), come out of oRPC as:

{ "type": "object", "propertyNames": { "type": "string", "enum": ["a", "b"] }, "additionalProperties": V }

(Zod adds required.) A record keyed by a reusable enum (Zod .meta({ id }), Pydantic dict[Enum, V]) writes propertyNames: { "$ref": "#/components/schemas/Lang" } instead. 3.0 has no propertyNames, so it was removed, generators typed the record as Record<string, V>, and an enclosing oneOf became anyOf because the branch got looser.

In 3.1 to 3.0, such a propertyNames now becomes one property per name, each sharing the converted additionalProperties schema, plus additionalProperties: false. That is exact, so an enclosing oneOf stays.

It applies only when propertyNames holds just an enum (and optionally type: "string"), or just a $ref to such an enum, with no properties, patternProperties, unevaluatedProperties or additionalProperties: false beside it. Anything else keeps the current behavior (removed). A $ref with keywords beside it (such as maxLength) is not followed, since those narrow the names further.

Changes:

  • enumNames helper and a matching line in losesRestriction in src/v3.1-to-v3.0.ts, plus six lines in finishSchema. ctx now passes through loosen and losesRestriction so enumNames can follow a $ref with the existing resolve.
  • Tests covering the Zod shape, the bare shape, the $ref shape, the cases where it is still removed, and the enclosing oneOf.
  • A README row in the 3.1 to 3.0 table.

Checked: pnpm test (264 pass) and pnpm run check clean. Ajv agrees with the 3.1 input on the converted output for the enum-key record variants; 700 fuzzed oRPC Zod/Valibot docs stay valid 3.0 with no lost instances; 76 of 80 real docs are byte-identical to main's output.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY

…e in 3.1 to 3.0

Zod and Valibot write a record with enum keys as a propertyNames enum
beside additionalProperties. 3.0 has no propertyNames, so generators typed
it as a string-keyed map and an enclosing oneOf became anyOf. Each name now
becomes a property sharing the additionalProperties schema, with
additionalProperties: false.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VvmRkSSBRTofwpppUksACY
@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-d4r2o8 (a8c9208) with main (6a906e9)

Open in CodSpeed

…moved

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

@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 critical issues — minor suggestions inline.

Reviewed changes

  • propertyNames enum → one property per name — enumNames detects the Zod/Valibot record shape and finishSchema rewrites it into properties + additionalProperties: false; losesRestriction treats it as exact so an enclosing oneOf stays.
  • Test — covers the Zod shape, the bare shape, the removal cases, and the enclosing oneOf.
  • README — new 3.1→3.0 table row for the enum case.

I checked semantic equivalence with an Ajv 2020-12 differential (49 applicable schema combos × 56 instances): input and converted output agreed everywhere except __proto__ enum names, which is an Ajv quirk (reproducible with a hand-written properties: { "__proto__": {} } schema) rather than a converter defect. pnpm test passes (263) and uncheck is clean on the touched files.

Pullfrog  | Fix it ➔ | 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 critical issues — one minor documentation suggestion inline.

Reviewed changes

Since the prior review (dbb858f), cea5a3e adds assertions to the propertyNames enum test pinning the cases where the conversion does not apply: additionalProperties: false, patternProperties, and a propertyNames carrying an extra keyword (minLength, type: "number"), plus an enum beside description: undefined. No production code changed.

  • propertyNames enum → one property per name — enumNames detects the Zod/Valibot record shape and finishSchema rewrites it into properties + additionalProperties: false; losesRestriction treats it as exact so an enclosing oneOf stays.
  • Tests — the Zod shape, the bare shape, the exact-conversion cases, the removal cases (now including additionalProperties: false and patternProperties), and the enclosing oneOf.
  • README — new 3.1→3.0 table row.

I re-ran the mutation check on the additionalProperties === false guard: with the new test in place, removing that guard now fails the suite (previously all 263 passed), so the guard is genuinely covered. pnpm test is green and uncheck is clean on the touched files.

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

@pullfrog

pullfrog Bot commented Oct 24, 2026

Copy link
Copy Markdown

Follow-up to my review (the inline anchor did not attach): the new README row lists properties, patternProperties, and unevaluatedProperties as the conditions that suppress the propertyNames enum conversion, but not additionalProperties: false, which also suppresses it (the guard in enumNames). Consider adding it so the documented condition matches the code — e.g. { propertyNames: { enum: ['a'] }, additionalProperties: false } is left as { additionalProperties: false } rather than expanded into properties. Everything else in the PR checks out: the conversion is exact (Ajv differential agrees), the enclosing oneOf correctly stays, and the new tests now fail if the additionalProperties: false / patternProperties guards are removed.

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

dinwwwh commented Oct 24, 2026

Copy link
Copy Markdown
Member Author

Re the README row: I'm leaving it as is. With additionalProperties: false beside it, only {} matches whether or not propertyNames is there, so dropping it changes nothing. The guard is there to keep the output short, and listing it in the row would only make the row longer.


Generated by Claude Code

Zod's .meta({ id }) enum keys and Pydantic dict[Enum, V] write a record's
keys as a $ref to a reusable enum. It is followed when nothing sits beside
it, so the record still gets one property per name.

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

@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 critical issues — one minor documentation note inline.

Reviewed changes

Since the prior review (cea5a3e), a8c9208 extends the propertyNames enum conversion to follow a $ref.

  • $ref-keyed propertyNames — enumNames now resolves a $ref that stands alone in propertyNames to its target and reads the enum from there, and ctx threads through losesRestriction/loosen so the looseness check sees the same resolved names the rewrite does.
  • Tests — a new case covers a $ref to a reusable enum in components/$defs, the enclosing oneOf staying a oneOf, and the removals for a non-enum target and a $ref carrying a sibling keyword.
  • README — the 3.1→3.0 table row now names the $ref form.

pnpm test (264) and pnpm run check (sherif/oxlint/oxfmt/tsc) are green on this commit.

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

Comment thread packages/downgrader/README.md
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