Repository navigation
test(downgrader): pin an enum branch beside null, a $ref beside a nullable type list, and a nullable type list's looseness - #369
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
… 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
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 — a test-only PR adding three assertions to packages/downgrader/tests/v3.1-to-v3.0.test.ts that pin converter behavior previously unguarded against surviving Stryker mutants.
- Enum branch beside null — pins that
anyOf: [{ type: ['string','null'], enum: ['a'] }, { type: 'null' }]stays a pair, guarding theisOnlyNullexception inisSameBranchso an enum that rejects null is not treated as the null branch. $refbeside a nullable type list — pins that a nullable pair targeting{ $ref: Base, type: ['object','null'] }stays a pair, guarding'$ref'inNOT_NULLABLE_KEYWORDSso the target's$ref(which rejects null) is honoured.- Nullable type list's looseness — pins that
oneOf: [{ $ref: T }, { type: 'integer' }]withT = { type: ['array','null'], nullable: true }keeps itsoneOf, guardingout.nullable !== trueinfinishSchemasoTis not marked loose.
I re-ran the test file on the unmodified source (137 passing) and applied each of the three mutations in place: every one makes exactly the intended assertion fail, so none of the new tests is vacuous. uncheck on the changed file also passes oxlint, oxfmt, and tsc. The body's survivor arithmetic is consistent, and the assertions match the existing comment density and idiom of the surrounding tests.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

🤖 Generated with Claude Code
https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
Test-only. I applied each surviving Stryker mutant to a copy of the source and compared its output with main's on 50k fuzz docs. Six mutants change output, and no test caught them:
isSameBranch'sname === 'enum' && isOnlyNull(a[name])(3 mutants). These take{ type: [string, null], enum: ['a'] }for the null branch beside it, so the pair merges into{ type: string, enum: ['a'], nullable: true }. That output loses null, because theenumholds no null. A new assertion pins theanyOfof both.'$ref'inNOT_NULLABLE_KEYWORDS→"". This mutant lets a target written as{ $ref: Base, type: [object, null] }count as admitting null, so a pair to it collapses to the bare$ref, which loses null when Base rejects it. A new assertion pins that the pair stays.out.nullable !== truein finishSchema (2 mutants). These mark{ type: [array, null], nullable: true }as loose, so aoneOfwith a$refto it becomes ananyOf. A new assertion pins theoneOf.Each assertion passes on main and fails under its mutants. I checked the rest by hand. The other 4 survivors that change output are equivalent: 2 only reorder keys (
patternProperties/prefixItemsguards), 1 was a mis-parenthesized copy of a mutant, and 1 is the known binary-format baseline survivor.Generated by Claude Code