Repository navigation
fix(downgrader): convert $ref targets kept in an extension - #376
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
…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
… 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
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
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 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 —
isInlinednow treats#/x-,#/components/x-, andx--prefixed keys right under a component schema as unconverted, so a$refinto them is replaced by its converted target. The oldrenamedflag's!key.startsWith('x-')exemption is removed. - Cycle handling — the
inlinehelper cuts back-references to{}viactx.inlining, so a self-referential extension target terminates (covered by the newLoopcase). - Tests and docs — the new
extensionssuite covers root#/x-ext,#/components/x-schemas, parameter refs, and cycles; the NocoDB-style test now pins that anx-noteextension stays as written while a$refinto it inlines to the converted schema. The README states the rule and the serialized-size cost.
deepseek-v4.1-flash-1017 (free via Pullfrog for OSS) | 𝕏

🤖 Generated with Claude Code
https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
Problem
Extensions pass through unconverted. When a
$refpoints into one, the target was left as written and never converted. This happens at the document root (#/x-...), incomponents(#/components/x-...), or under a key that is alreadyx-right under a component schema. So 3.1 syntax such as type lists,const,type: "null"andexamplesreached the 3.0 output.Real 3.1 documents do this:
#/x-ext/<hash>. Examples are GoDaddy domains-v3 (119 refs) and cloudflare/nimbus fixtures.#/x-jsonrpc-envelope, whosejsonrpcis aconst.#/x-shared/...and#/x-responses/...unions ofconstand null.This also breaks the loosen-only rule. For example, take
oneOf: [{ $ref: '#/x-ext/tag' }, { type: 'null' }]withx-ext.tag: { const: 'a' }. A 3.0 reader ignoresconst, 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
isInlinednow treats these$refs as pointing at unconverted content and replaces them with their converted targets, the same way$refs into$defsor removed parts are handled. The NocoDB check no longer exempts keys that are alreadyx-. 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
$refrepeats its target once serialized. GoDaddy domains-v3's 3.0 output grows from 97 KB to 277 KB this way.Checks
packages/downgrader/srcis 100% for statements, branches, functions and lines.isInlined: 69 of 70 mutants are killed. The 1 survivor is the known equivalentname === 0 || end === -1guard.Generated by Claude Code