Skip to content

fix(downgrader): replace a $ref into a schema root's key that becomes an extension - #377

Merged
dinwwwh merged 24 commits into
mainfrom
claude/project-thread-ttohne
Nov 5, 2026
Merged

dinwwwh merged 24 commits into
mainfrom
claude/project-thread-ttohne

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Nov 5, 2026

Copy link
Copy Markdown
Member

Requested by dinwwwh · project thread

Problem

#374 fixed $refs into a key that 3.0 renames to an extension, but only under a component schema in a document. downgradeSchemaV31ToV30 renames a root key that no keyword names in the same way, yet left the $ref as written:

downgradeSchemaV31ToV30({
  type: 'object',
  properties: { image: { $ref: '#/CustomTypes/Path' } },
  CustomTypes: { Path: { type: 'string' } },
})
// properties.image stays { $ref: '#/CustomTypes/Path' }, but the key is now x-CustomTypes

The output $ref points at nothing. This happens in real schemas. I ran about 106k standalone schemas from the hunt corpora through downgradeSchemaV31ToV30, and also converted each one as a component of a document. They differ in 7 schemas from 2 projects, and every difference is this case (#/CustomTypes/Path, #/CustomTypes/agentArgument, #/Assets).

Fix

isInlined now gives a $ref's first key the same test when the root being converted is a schema, which is the test #374 gives the key after #/components/schemas/<name>/. That test moves into a small becomesExtension helper. The root is recorded in module state beside the other per-run sets and cleared in the same finally.

What stays the same:

  • Refs into keys 3.0 keeps, such as #/properties/a or #/not, stay bare.
  • $ref: '#' stays as written.
  • Refs in a document, such as #/paths/... or a custom root key of the document, are unaffected.

I updated the README row on keywords 3.0 does not define.

Checks

  • Tests: 313 pass, downgrader coverage stays at 100%, and pnpm run check passes.
  • All 37,810 corpus documents give byte-identical outputs before and after. Total time was 59.4 s before and 53.8 s after, which is run-order noise.
  • Across the 106,149 standalone schemas, the standalone and in-document outputs now differ in 0 schemas (7 before). The in-document outputs had already passed the value oracles in earlier rounds.

Generated by Claude Code

claude added 23 commits November 2, 2026 08:51
3.1 has no default for a missing or unmapped discriminating value, so tools
following the kept discriminator (Hey API types and Zod) pin the default
schema's tag to its name and reject values 3.2 routes to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs
… none of

3.1 lets a Discriminator Object carry specification extensions, 3.0 does
not, so an `x-` key there made the 3.0 output invalid. Microsoft.OpenApi's
3.0 reader reports it as an error, for example on the
`x-discriminator-default` that SwaggerHub's NPP and APDS specs write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs
…m tag with a default required

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs
…he tag default

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs
…lue enum tag

Hey API pins each discriminator branch to its tag when the tag is a
one-value enum, as a const becomes, so its runtime plugins reject a
value that leaves out a tag with a default (Pydantic's Literal default).
From 3.1's const it uses the branch as written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs
…r 3.0 rules

Python's openapi-schema-validator and jsonschema-rs with draft 4 rules
reject a number with a zero fraction for type integer, as 3.0.3 defined
an integer; 3.0.4, which the output names, accepts it as 3.1 does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs
…e readOnly

Hey API 0.99 drops a `{ type: "object" }` branch with nothing else in it from an
`anyOf` in a schema that holds a readOnly or writeOnly property, so its types,
Zod and Valibot reject an object for a `type` list with "object" in 3.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs
…Properties

A $ref into `items`, `additionalProperties`, `$defs` or `definitions` is
replaced by its target, but the check read the whole pointer, so a
component named one of those words, as Celigo names a recursive mapping
row `items`, was inlined at every use and its recursion cut to {} one
level down. The segment after `#/components/<type>/` is a name, so only
the part after it is checked now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs
YAML reads `type: [string, null]` with a JSON null, not the string "null".
The 3.0 output dropped that entry, so it rejected null; TCGdex writes its
nullable fields this way. Count it as "null" when checking a type list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs
NocoDB's 3.1 document groups schemas under one component and refers to
them as `#/components/schemas/FieldOptions/SingleLineText`. 3.0 allows no
such keys, so they became `x-` extensions and all 35 of those `$ref`s
dangled. Replace a `$ref` through such a key, right under a component
schema, with its converted target, as for `$defs`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs
…to claude/project-thread-ttohne

# Conflicts:
#	packages/downgrader/tests/v3.1-to-v3.0.test.ts
… an extension

downgradeSchemaV31ToV30 renames a root key that no keyword names, such as
SchemaStore-style `CustomTypes`, to `x-CustomTypes`, but left a `$ref` such
as `#/CustomTypes/Path` as written, so it pointed at nothing. #374 already
replaces such refs under a component schema; the same rule now applies to
the root of a converted schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs
@codecov

codecov Bot commented Nov 5, 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 Nov 5, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 29 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/project-thread-ttohne (ffcbb96) with main (063278f)

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.

ℹ️ Minor suggestions only — one stale docs bullet for the newly documented scope.

Reviewed changes

  • Root-key extension refs — isInlined now inlines a standalone schema's $ref when the first path key becomes a 3.0 extension, so #/CustomTypes/Path no longer dangles after CustomTypes is renamed to x-CustomTypes.
  • Shared helper — the keyword-rename test is factored into becomesExtension and reused by the existing component-schema branch.
  • Per-run state — schemaRoot records the schema entry point and is cleared with the other module-level pass state in the same finally.
  • Docs and test — the README row is updated and a test pins the new behavior alongside a kept #/properties/... ref and #.

The code path checks out: downgradeSpecV31ToV30 leaves schemaRoot undefined, so document conversion is untouched, and refs into removed keywords (#/prefixItems/0) still dangle as documented. All 313 downgrader tests and uncheck on the touched files pass.

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

Comment thread packages/downgrader/README.md Resolved
@dinwwwh
dinwwwh merged commit c026324 into main Nov 5, 2026
9 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