Skip to content

fix(downgrader): follow a null pair nested in a null pair around a $ref loop - #363

Merged
dinwwwh merged 39 commits into
mainfrom
claude/project-thread-rixcs1
Nov 2, 2026
Merged

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

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Nov 2, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq

What

#356 keeps the null of an anyOf pair whose $ref leads 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 as

T:
  anyOf:
    - anyOf: [{ $ref: '#/components/schemas/T' }, { type: 'null' }]
    - type: 'null'

It also missed the same shape with oneOf inside, 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 was T: { $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 T keeps 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 $ref loops, 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 was T: { $ref: T } before. On the loop-focused dumps (12k docs), jsonschema-rs still reports 0 lost.

Tests

The #356 test now adds a nested anyOf pair, a nested oneOf pair, a nested union without null (left as is), and a boolean branch. The new asserts fail on 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
…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
…ef loop

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

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

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 29 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/project-thread-rixcs1 (96fe14d) with main (25774db)

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.

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 — refAdmitsNull now flattens a branch that is itself a union holding a null branch via the new nonNullBranches, so the loop walk reaches the inner $ref and 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/oneOf cases, 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; uncheck on both files is green.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Comment thread packages/downgrader/src/v3.1-to-v3.0.ts Resolved

@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. 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 — nonNullBranches now threads a seen Set of 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 $ref now 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.

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

@dinwwwh
dinwwwh merged commit 69c521c into main Nov 2, 2026
9 checks passed
dinwwwh added a commit that referenced this pull request Nov 2, 2026
…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>
dinwwwh added a commit that referenced this pull request Nov 2, 2026
…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>
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