Repository navigation
fix(downgrader): follow a null pair nested in a null pair around a $ref loop - #363
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
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.
Important
nonNullBranches recurses into nested unions with no visited guard, so a programmatic input whose object graph has an identity cycle (or shared nested arrays) through a null-bearing union makes refAdmitsNull overflow the stack. The same input converts on main, and object-identity cycles are otherwise supported by this converter — details inline.
Reviewed changes
- Follow a null pair nested in a null pair —
refAdmitsNullnow flattens a branch that is itself a union holding a null branch via the newnonNullBranches, so the loop walk reaches the inner$refand keeps the pair instead of collapsing it to a bare$ref(packages/downgrader/src/v3.1-to-v3.0.ts). - Tests — the #356 loop test gains nested
anyOf/oneOfcases, a null-free nested union (left whole), and a boolean branch (packages/downgrader/tests/v3.1-to-v3.0.test.ts). All 137 tests in the file pass;uncheckon both files is green.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq
There was a problem hiding this comment.
✅ No new issues found. The cycle guard resolves the prior review's concern and does not affect parsed (acyclic) input.
Reviewed changes
- Guarded the nested-union walk against cycles —
nonNullBranchesnow threads aseenSetof the unions already flattened and returns[]on a revisit, so an identity cycle through null-bearing nested unions terminates instead of overflowing the stack (packages/downgrader/src/v3.1-to-v3.0.ts:986). - Added a cyclic-input test — a hand-built
B = { anyOf: [{ anyOf: [B, null] }, null] }behind a$refnow converts instead of throwing (packages/downgrader/tests/v3.1-to-v3.0.test.ts:1394).
The guard only fires on identity repeats, which parsed JSON (a tree) never produces, so behavior for real documents is unchanged — it matches the repo's existing cycle-guard convention. Verified locally: pnpm vitest run packages/downgrader/tests/v3.1-to-v3.0.test.ts passes (137 tests) and uncheck is green on both files.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…ll pair (#364) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq ## What Test-only. Stryker on main after #363 reports 77 survivors, the same count as before #363. Two survivors are new: - `const others = Array.isArray(anyOf) ? nonNullBranches(anyOf) : []`: `[]` replaced by `["Stryker was here"]`. This is the existing equivalent survivor on that line, renamed by #363. - The cycle guard's `return []` in `nonNullBranches`, replaced by `["Stryker was here"]`. This one is real. The #363 cycle test passes either way, because the only branch left is the revisited one, so a filler branch and no branch both end the walk. The new assert adds a `$ref` beside the cycle (`B = { anyOf: [{ anyOf: [B, null, $ref A] }, null] }`, with `A` a pair around `$ref B`). The guard leaves only the `$ref`, so the walk follows it back to `B` and keeps the pair. A filler branch would make it give up and drop the null. Replacing the guard's result by hand now fails the test. --- _Generated by [Claude Code](https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…to it in 3.0 (#365) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq ## Problem A pair of null and a `$ref` becomes the bare `$ref` when 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 `$ref` to 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: - null written as `{ const: null }` or `{ enum: [null] }` - a duplicate `$ref`, as `anyOf: [$ref, $ref, null]` - pairs nested in a `oneOf`, or nested more than one level deep ```yaml A: anyOf: - anyOf: [{ $ref: '#/components/schemas/A' }, { const: null }] - type: 'null' # before: A: { $ref: '#/components/schemas/A' } # after: A: { anyOf: [{ $ref: '#/components/schemas/A' }, null branch] } ``` ## Fix 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 `$ref`s 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 - Tests pass with 100% branch coverage. - The loop fuzzer finds 0 bare loops over 6 seeds × 20k docs (it found 586 on main). - jsonschema-rs finds no lost values on the regenerated fuzz dumps. - On the fuzz dumps, outputs differ from main only on docs with such loops. --- _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
What
#356 keeps the null of an
anyOfpair whose$refleads back to it through other such pairs, because the bare$refs that would result match nothing. Its walk missed one case: a pair nested in the target's own pair, such asIt also missed the same shape with
oneOfinside, or with annotations or keywords 3.0 drops beside the inner pair (examples,$comment,if,else,propertyNames, ...). In 3.1 these admit null. The 3.0 output wasT: { $ref: '#/components/schemas/T' }, which drops the null and which Ajv cannot compile (Maximum call stack size exceeded), though it compiles the input.The walk now also looks inside a branch that is itself a union holding a null branch. So
Tkeeps its pairs, as #356 does for the flat loop. Returning "doesn't admit null" only keeps a null branch, so a wrong answer could cost a redundant branch but never a value.Scope
This only touches non-consuming
$refloops, which are degenerate. Re-converting today's fuzz dumps (about 140k docs from several generators) changed 0 to 4 docs per 20k, and every one of them wasT: { $ref: T }before. On the loop-focused dumps (12k docs), jsonschema-rs still reports 0 lost.Tests
The #356 test now adds a nested
anyOfpair, a nestedoneOfpair, a nested union without null (left as is), and a boolean branch. The new asserts fail on main.Generated by Claude Code