Repository navigation
fix(downgrader): keep a null pair only when its $ref would loop back to it in 3.0 - #365
Conversation
…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
…give a composition A discriminator beside none of oneOf, anyOf and allOf does nothing in 3.1, as Pydantic writes one beside the $ref of a tagged union with a single member (ElevenLabs). The $ref becomes an allOf in 3.0, and a type list an anyOf, which 3.0 validators then apply the discriminator to, rejecting values without the tag. Such a discriminator is now dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
…e list 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
…allOf Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
…ef loop 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
…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
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will not alter performance
Comparing Footnotes
|
There was a problem hiding this comment.
ℹ️ No critical issues — one documentation note below.
Reviewed changes
- Collapse now output-checked —
mergeNullablecollapses a null/$refpair to the bare$refwhen the target admits null, recording the pair in the newcollapsedPairslist; the input-walk helpersrefAdmitsNullandnonNullBranchesare removed. - Loop restore post-pass — after the deferred union pass,
leadsBackwalks the converted output from each collapsed$refand restores the pair for any that reaches itself through bare$refs, so a$ref-only cycle that matches nothing no longer survives. - Tests — expectations updated for pairs that now lead into a loop (or whose target keeps a
description), plus new cases for nested pairs, duplicate$refs, andconst: null.
ℹ️ README not updated for the loop exception
The 3.1→3.0 table row for the nullable $ref pair still says the pair becomes "the $ref alone, when nothing else sits beside the anyOf". After this change that holds only when the $ref does not lead back to the schema; a loop keeps the pair. Since the README is the converter's spec, that row should note the exception.
Technical details
# README: document the loop exception for the nullable `$ref` pair
## Affected sites
- `packages/downgrader/README.md:192` — the row for `anyOf` + null whose other branch is a `$ref` to a null-admitting target; its "In 3.0" cell states the bare `$ref` unconditionally.
## Required outcome
- Note that the pair stays when the `$ref` would lead back to the schema through bare `$ref`s, since the bare `$ref` would then match nothing.
## Suggested approach (optional)
- Extend the "In 3.0" cell of that row, mirroring the wording of the `leadsBack` comment (`packages/downgrader/src/v3.1-to-v3.0.ts:1739`).deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
|
On the README note: I'm leaving the row as it is. The loop exception isn't new. Since #356, main has kept the pair around a loop without a note in that row, and this PR only changes how the loop is detected. The exception also only applies to a schema that in 3.1 admits nothing but null through itself. No tool writes that: 0 of the 1,052 real docs we checked contain one. A note in the table cell would make every row wider, since oxfmt pads every row of the table, and it would help no one reading it. The comment on Generated by Claude Code |
… 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
… its edges (#366) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq A follow-up to #365, from a Stryker run on it. - `leadsBack` drops its bare-key test and follows each `$ref` by itself. 3.0 output never has a `$ref` with siblings: there are 0 among the outputs of 46k fuzz docs. The loop is also shorter now. - Tests cover two cases: - an object cycle in a pair's target. #365 dropped this case with the input walk, and it was the only test that killed an `enumUnionType` mutant. - a pair whose `$ref` target moves in 3.0 (into a type list's object branch). There the chain leads nowhere, and the old `||` mutant would have thrown. ## Checks - Tests pass with 100% branch coverage. - Output is identical to main on the c1, c2, kn1 and kt1 fuzz dumps (46k docs). - The loop fuzzer still finds 0 bare loops. - Stryker: 78 survivors, down from 81 on main. The 3 new survivors left from #365 are equivalent: the initial value of `collapsedPairs`, and its `length > 0` guard, which only skips work. --- _Generated by [Claude Code](https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq)_ --------- Co-authored-by: Claude <noreply@anthropic.com>

🤖 Generated with Claude Code
https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
Problem
A pair of null and a
$refbecomes the bare$refwhen the target admits null. #356 and #363 kept the pair around a loop by walking the input. In 3.1 such a loop admits only null. The walk missed some loops, and those still became a$refto themselves in 3.0, which matches nothing (Ajv and the Python validators overflow the stack on it). A fuzzer that builds small sets of nested pairs found about 600 such docs in every 20k. The missed spellings:{ const: null }or{ enum: [null] }$ref, asanyOf: [$ref, $ref, null]oneOf, or nested more than one level deepFix
Each pair whose target admits null now becomes the bare
$ref. After the post-pass, any of those that leads back to itself through bare$refs in the 3.0 output gets its pair back. The check runs on the output, so it catches every spelling. The input walk is removed (refAdmitsNull,nonNullBranches).A pair that leads into such a loop without being on it now becomes the bare
$ref, because its target keeps its pair and admits null. A pair whose target keeps a description beside its pair also becomes the bare$ref, since the description breaks the loop. The test expectations for those cases are updated.Checks
Generated by Claude Code