Skip to content

fix(downgrader): convert $ref targets kept in an extension - #376

Merged
dinwwwh merged 61 commits into
mainfrom
claude/project-thread-rixcs1
Nov 4, 2026
Merged

dinwwwh merged 61 commits into
mainfrom
claude/project-thread-rixcs1

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Nov 4, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq

Problem

Extensions pass through unconverted. When a $ref points into one, the target was left as written and never converted. This happens at the document root (#/x-...), in components (#/components/x-...), or under a key that is already x- right under a component schema. So 3.1 syntax such as type lists, const, type: "null" and examples reached the 3.0 output.

Real 3.1 documents do this:

  • Stoplight's bundler puts every bundled file under #/x-ext/<hash>. Examples are GoDaddy domains-v3 (119 refs) and cloudflare/nimbus fixtures.
  • CMSIS csolution-rpc uses #/x-jsonrpc-envelope, whose jsonrpc is a const.
  • datamodel-code-generator has fixtures with #/x-shared/... and #/x-responses/... unions of const and null.

This also breaks the loosen-only rule. For example, take oneOf: [{ $ref: '#/x-ext/tag' }, { type: 'null' }] with x-ext.tag: { const: 'a' }. A 3.0 reader ignores const, so null matches both branches, and the 3.0 output rejects a null that the input accepts. In a fuzz of grouped and extension refs checked by the Go santhosh-tekuri oracle, this lost 9 accepted values out of 11.9k. With the fix it loses 0.

Change

isInlined now treats these $refs as pointing at unconverted content and replaces them with their converted targets, the same way $refs into $defs or removed parts are handled. The NocoDB check no longer exempts keys that are already x-. Recursion is cut to {} at the back reference, as for $defs. The extension itself stays as written.

The README states the rule. It also extends the existing size note: each such $ref repeats its target once serialized. GoDaddy domains-v3's 3.0 output grows from 97 KB to 277 KB this way.

Checks

  • Tests: 312 pass, and coverage of packages/downgrader/src is 100% for statements, branches, functions and lines.
  • Stryker on isInlined: 69 of 70 mutants are killed. The 1 survivor is the known equivalent name === 0 || end === -1 guard.
  • Outputs on all 72 fuzz dumps (1.12M docs) are identical to main.

Generated by Claude Code

claude added 30 commits November 2, 2026 00:51
…and repeated keywords beside properties

Stryker on 94c60c7 leaves 77 mutants alive where 0.5.0 left 70. Seven of
the new ones are untested branches from #313, #314 and #318:

- cutHolders.clear() removed: a $ref object cut in one call still tells no
  oneOf branch apart in the next.
- follow's check for a $ref into a target being inlined removed: a $ref to
  a component whose own $ref into that target is cut only later keeps a
  oneOf whose branches now overlap.
- isOneWay ignoring writeOnly, or not following a $ref to the tag.
- repeats(): a repeated additionalProperties beside the same properties,
  and another keyword beside different properties.
- mayRepeatUnseen counting null as an object.

Each of these mutants now fails a test. No source change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
…me holds undefined

#324 keeps a requirement name only when components.securitySchemes has it
as an own key, so a scheme entry holding undefined, as spreading options
often leaves, still counted as a scheme. The 3.1 output leaves that entry
out, so the requirement named a scheme the document no longer defines,
and the same input with the key deleted gave a different output.

The check now also needs the entry to hold a value, as every other key
holding undefined counts as missing.

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

Since #331 a scheme holding undefined fails the scheme check first, so
the oRPC undefined-key test no longer reaches the check that leaves out a
requirement name holding undefined, and Stryker leaves that mutant alive.
A requirement name holding undefined whose scheme exists now must come
out as a missing key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
#340 reads each branch's `enum` through optional chaining to count the
enums before merging them. No test holds a null branch there, so Stryker
leaves the mutant without `?.` alive, which throws on
`anyOf: [null, { const: 'a' }, { const: 'b' }]`. The malformed-input test
now converts such a union.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
…gged $ref

#344 turned the only case that reached isTaggedRef's sibling-key check
into a bare $ref, so three Stryker mutants there survived. A $ref to a
tagged target beside a matching type and minProperties keeps the allOf.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
…meter resolves one entry off

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
A 3.0-style `nullable` beside a `$ref` without a `type` means nothing in
3.1 and is removed, so the `$ref` became an `allOf` with nothing beside
it. It now stays the bare `$ref`, also as a discriminator branch and
beside annotations to a tagged union.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
`$id`, `$anchor`, `$comment`, `$schema`, `$defs` or `definitions` beside a
`$ref` restrict nothing and 3.0 removes them, but the `$ref` still became
an `allOf` with nothing beside it. They now count as missing, as #354 did
for a 3.0-style `nullable`, so the `$ref` stays bare, also as a
discriminator branch and to a tagged union.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
A pair of null and a `$ref` becomes the `$ref` alone when its target admits
null. When the target is itself such a pair that leads back, as in
`A: { anyOf: [{ $ref: A }, { type: null }] }`, every pair in the loop became
a bare `$ref`, which matches nothing, so null, which 3.1 accepts, was lost.
A loop of such pairs now keeps its pairs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
A pair beside a `$comment` or an empty `examples`, which 3.0 drops, still
collapsed to a bare `$ref` inside a loop, as the walk read the input's key
count. It now follows any target with an `anyOf`, which `admitsNull` already
limits to annotations beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
claude added 25 commits November 2, 2026 15:47
…ef loop

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
…to it in 3.0

The walk over the input that kept a pair of null and a $ref around a loop
missed null written as const or enum, a duplicate $ref, and other nesting,
so such a loop still became a $ref to itself. Each pair whose target admits
null now becomes the $ref alone, and one that then leads back to itself
through bare $refs in the output keeps its pair.

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

3.0 output has no $ref with siblings, so the loop check needs no bare-key
test. Tests cover an object cycle in a pair's target and a $ref whose
target moves in 3.0, which Stryker found unchecked after #365.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
… on both sides of a branch

Stryker left three mutants alive: mergeNullable's anyOf-only guard (a oneOf
of null and a $ref whose target admits null gained a $ref beside it) and the
post-pass check that lets an anyOf with null twice merge (null on both
sides of one branch stayed a oneOf).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
…lable type list, and a nullable type list's looseness

A differential run of each surviving Stryker mutant over 50k fuzz docs found
six that change output: isSameBranch took an enum branch for the null branch
beside it (losing null), a $ref beside a type list with null counted as
admitting null (losing null), and a 3.0-style nullable beside a type list
that already holds null marked the schema loose (a oneOf to it became an
anyOf).

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

A null alone gets no string format, which kills the last Stryker survivor
on #372's hasType change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
…t type stays as written

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
Extensions pass through unconverted, so a $ref into one at the document
root, in components, or right under a component schema (as Stoplight
bundles files into x-ext) kept 3.1 syntax in the 3.0 output. A oneOf of
such a const target and null then rejected null in 3.0. These $refs are
now replaced by their converted targets, like $refs into $defs.

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

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

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 29 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/project-thread-rixcs1 (92d20db) with main (55d11ff)

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
This run reviewed the net diff of PR #376 against main: the isInlined change in packages/downgrader/src/v3.1-to-v3.0.ts, the new and updated tests in packages/downgrader/tests/v3.1-to-v3.0.test.ts, and the README note.

  • Inlining refs into extensions — isInlined now treats #/x-, #/components/x-, and x--prefixed keys right under a component schema as unconverted, so a $ref into them is replaced by its converted target. The old renamed flag's !key.startsWith('x-') exemption is removed.
  • Cycle handling — the inline helper cuts back-references to {} via ctx.inlining, so a self-referential extension target terminates (covered by the new Loop case).
  • Tests and docs — the new extensions suite covers root #/x-ext, #/components/x-schemas, parameter refs, and cycles; the NocoDB-style test now pins that an x-note extension stays as written while a $ref into it inlines to the converted schema. The README states the rule and the serialized-size cost.

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

@dinwwwh
dinwwwh merged commit 063278f into main Nov 4, 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