Skip to content

fix(downgrader): count a readOnly or writeOnly schema as loose - #318

Merged
dinwwwh merged 14 commits into
mainfrom
claude/project-thread-h3q238
Nov 1, 2026
Merged

dinwwwh merged 14 commits into
mainfrom
claude/project-thread-h3q238

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Requested by dinwwwh · project thread

What

Part of making the 3.1 → 3.0 downgrade only ever lose restrictions, never add them.

OAS 3.0 applies required to a readOnly property in responses only, and to a writeOnly one in requests only. In 3.1 (JSON Schema 2020-12) both are annotations, so required holds in both directions. The 3.0 output therefore accepts more than the input, but the converter did not count that as a lost restriction, so:

  • not: { type: object, properties: { id: { readOnly: true } }, required: [id] } stayed a not, and the request {}, which 3.1 accepts, was rejected by 3.0.
  • A oneOf told apart only by a tag one branch requires as readOnly stayed a oneOf. With A: { properties: { kind: { const: a } } } and B: { properties: { kind: { const: b, readOnly: true } }, required: [kind] }, the request {} matches only A in 3.1 but both in 3.0, so 3.0's oneOf rejected it.

The fix is kept minimal: finishSchema counts any readOnly or writeOnly schema as loose. The existing checks then carry that up to every object around it and every $ref to it, so no walk over required names is needed. tagsOf no longer treats a one-way required property as a tag that tells branches apart; that check reads through $refs, as 3.0 reads each target in its place.

README: one new row in the 3.1 → 3.0 schema table.

Tests

includes a readOnly or writeOnly schema covers the not case for both keywords, an optional property behind a two-$ref chain, a oneOf tagged only by readOnly tags (becomes anyOf), one tag required both ways (stays oneOf), and a $ref cycle in the tag check. pnpm check, pnpm test and coverage (100%) pass.

Tradeoffs

  • Code generators. An object with any readOnly or writeOnly property now counts as loose, even when it does not require that property. That is more than the rule needs, but it keeps the logic to one line. A oneOf over such objects that is not told apart by type or by a tag required both ways now comes out as anyOf, which openapi-generator and others model as a different union type, and a not over one is dropped. Tagged unions whose tag is not readOnly stay oneOf.
  • Speed. The check is two property reads per schema.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV

claude added 12 commits November 1, 2026 05:44
… no oneOf branch apart, and takes null

Where several $refs enter the same cycle, the first copy made is reused, so
a later one can hold a $ref that was cut to {} while another target was
being inlined. isDisjoint and the nullable pair merge read such a $ref in
the input, saw its target's type or tag, and kept a oneOf that the {} then
let a value match twice. The holders of cut $refs are now recorded, and
follow() and throughRef() read them as keeping nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…operties beside other properties, out of enum merging

A oneOf rejects a value two of its branches share, so mergeEnums keeps a
oneOf whose literals repeat one. It compared them serialized, which tells
equal objects with their keys in another order apart, so it merged such a
oneOf into an enum that took the shared value. A oneOf of object or array
literals now stays as it is. And an additionalProperties in a literal branch
counted as repeating the one around it although that one sits beside other
properties, which it then let through, so it repeats only beside the same
properties.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…side annotations

enumNames read the names at the end of the propertyNames $ref chain, past a
$ref with an enum, maxLength or other keyword beside it that narrows the
names further. The record then got a property for each name of the last
enum, allowing keys the input rejects, and counted as converted exactly, so a
not or oneOf over it stayed. The chain now stops at such a $ref, so the
record loses its key names and counts as loose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…iction around an alias component or a cycle of objects

#309 converts the input again when a not or oneOf read a $ref's target
before that target lost a restriction around a cycle. It missed two cycles.
A component that is only a $ref, an alias, has no output of its own, so the
output recorded for it was undefined; it now records the output of the target
its chain of kept $refs ends at. And a dereferenced document closes a cycle
with the object itself rather than a $ref; convertObject now tells
convertSchema when it meets a schema again, and one that was not loose then
but is once converted makes the next pass read it as loose. First visits pay
nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…ed-union $refs

The rerun recorded nothing for an alias whose chain passes through a $ref the
conversion inlines, such as one into $defs, or that is a $ref to a tagged union
beside only annotations, so a not over it around a cycle was kept over a target
that lost a restriction. sharedOutput now follows every $ref convertSchema
treats as an alias, and reads an inlined hop's output from its inlined copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…form

A schema that loses a restriction around a cycle made the conversion run
again, so a not or oneOf that read it too early could drop or widen. Only
such a choice changes when more schemas read as loose, so a pass that kept
no not, and no oneOf without a loose branch, needs no other. A document with
a recursive schema that loses a restriction and no not or oneOf now converts
in one pass, about half the time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
…y property as loose

OAS 3.0 applies a required readOnly property to responses only and a
required writeOnly property to requests only, while 3.1 requires it both
ways. The 3.0 output so loses that restriction, so a not or oneOf over
such an object must not keep it, and the property tells no oneOf branch
apart. The property is read through a chain of $refs too, as 3.0 reads
each target in its place.

Such a schema in a cycle, as a recursive DTO with a required readOnly id,
makes a document that keeps a not or oneOf convert twice, which the README
performance note now says.

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

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

Copy link
Copy Markdown

Merging this PR will degrade performance by 17.62%

2 warnings

⚠️ 1 benchmark is dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

⚠️ 1 benchmark with a significant change ran on different hardware

Its base and head results were measured on machines with a different CPU, core count or memory, so the change might not come from the code. Run base and head on the same runner type.

Learn more about Macro Runners

See affected benchmarks

❌ 1 regressed benchmark
✅ 28 untouched benchmarks
⏩ 2 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ order schema with $defs 1.2 ms 1.4 ms -17.62%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/project-thread-h3q238 (9811aeb) with main (cb94ddf)

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. ↩

… or a set per name

CodSpeed read the order schema bench 19% slower with the check. It now loops
over `required` once per schema, reads each property's fields directly, and
makes a visited set only past the first `$ref`. Instructions per conversion of
that schema drop from 4% over main to 2% under callgrind with CodSpeed's V8
flags. A cycle of `$ref`s, which never reaches a schema, is tested too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV

@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.

Approved. The change correctly detects objects whose required list names a readOnly/writeOnly property and marks them loose, so a not is dropped and a oneOf becomes anyOf when branches may overlap. isOneWay follows $ref chains with a cycle guard and only allocates a visited set when a $ref is present. tagsOf stops treating one-way required properties as branch-distinguishing tags. Tests cover both keywords, a two-$ref chain, a oneOf tagged only by readOnly, one tag required both ways, and an optional readOnly property; README updates match the behavior.

Non-blocking: isOneWay follows only $ref chains, not allOf members. A property whose readOnly/writeOnly sits in an allOf member while required sits on the parent won't mark the object loose. The code comment and README could make that $ref-only scope explicit, or a follow-up test could cover the allOf split case.

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

dinwwwh commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

On the non-blocking allOf note: this stays scoped to the property itself and its $ref chain. In 3.0 the "required one way only" rule belongs to the property's own readOnly/writeOnly, and a $ref stands in for its target, so those two count. A readOnly that sits only inside an allOf member is an annotation that 3.0 never says it collects, and reading it on every required property would add per-schema cost to a check CodSpeed already flagged. A readOnly written beside the allOf, which is how NestJS and the annotated $ref conversion write it, is on the property and is covered.


Generated by Claude Code

@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.

Blocking: the new isOneWay skips adding the first property to visited, so a self-referential $ref (P -> P) never reaches the cycle guard and loops forever. The previous version added the first node unconditionally and handled that case. The added cycle test covers a two-node cycle (Loop <-> Back) but not a self-cycle. Please add the first node to visited (or handle the self-cycle after the first resolve) and add a self-cycle test. The rest — the requiresOneWay split, the tagsOf filter, and the extra cycle test — looks good.

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

Marking the schema itself loose lets a $ref to it, and every object around
it, read as loose through the existing checks, so no walk over required
names is needed. Only the tag check still reads through $refs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7NmVyQoYtVBNPuE4uV
@dinwwwh dinwwwh changed the title fix(downgrader): count an object that requires a readOnly or writeOnly property as loose fix(downgrader): count a readOnly or writeOnly schema as loose Nov 1, 2026

@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.

Reviewed changes — the delta since the prior pullfrog review (9962db1, which requested changes; the blocking cycle bug is now resolved).

  • Rewrote isOneWay to add each node to its visited set before following that node's $ref, so a self-referential $ref (P -> P) terminates instead of looping forever. The prior blocking finding is fixed by structure, not by a special case.
  • Replaced the old requiresOneWay(schema.required, schema.properties) scan with a direct rule in finishSchema: any readOnly or writeOnly schema is marked loose. The existing loosen/convertSchemaRef propagation carries that to enclosing objects (out.properties/out.allOf/out.anyOf values loose) and to every $ref, so no walk over required names is needed. This is a deliberate over-approximation, which only ever drops a not or widens a oneOf to anyOf.
  • tagsOf now filters required names through isOneWay(properties[name]), so a tag that 3.0 honours in only one direction no longer tells oneOf branches apart, while a tag required both ways still does.
  • Updated the 3.1 → 3.0 README row and its tests to the broadened rule, and reverted the now-inaccurate limitations note about readOnly around a cycle.

I verified the rewrite with a throwaway probe covering a self-$ref cycle and a mutual A <-> B cycle: both terminate in milliseconds (probe removed). pnpm vitest run packages/downgrader/tests/v3.1-to-v3.0.test.ts passes 130/130, and uncheck on the three changed files passes oxlint, oxfmt, and tsc.

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

dinwwwh commented Nov 1, 2026

Copy link
Copy Markdown
Member Author

On the CodSpeed result for order schema with $defs: CodSpeed itself flags that this benchmark's base and head ran on different hardware. The bench schema has no readOnly or writeOnly, so the only new work on that path is two property reads per schema. Measured locally under callgrind with CodSpeed's V8 flags, one warmed conversion costs 1,356,928 instructions on main and 1,365,378 on this head (+0.6%).


Generated by Claude Code

@dinwwwh
dinwwwh merged commit e663c22 into main Nov 1, 2026
10 of 11 checks passed
dinwwwh added a commit that referenced this pull request Nov 2, 2026
…and repeated keywords beside properties (#330)

Stryker on 94c60c7 leaves 77 mutants alive where 0.5.0 left 70. Eight of the ten new survivors are untested branches from #313, #314 and #318 (the other two are equivalent): cutHolders.clear(), follow's check for a $ref into a target being inlined, isOneWay's writeOnly and $ref handling, repeats()'s additionalProperties clause, and mayRepeatUnseen's null check. 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
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