Skip to content

fix(downgrader): keep a $ref to a component named items or additionalProperties - #370

Merged
dinwwwh merged 14 commits into
mainfrom
claude/project-thread-ttohne
Nov 3, 2026
Merged

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

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Nov 3, 2026

Copy link
Copy Markdown
Member

Requested by dinwwwh · project thread

What

isInlined replaces a $ref into items, additionalProperties, $defs or definitions with its target, because the conversion can replace what sits there. It tested the whole pointer, though, so a $ref to a component named one of those words also matched, for example #/components/schemas/items.

Such a component was then inlined at every use. When it is recursive, the inlined copy is cut to {} one level down, so the output loses every constraint below that level and the named type is gone.

Now only the part after #/components/<type>/<name> is checked, since that segment is a name and not a keyword:

  • A keyword after the name still counts, so #/components/schemas/items/properties/rows/items is still replaced.
  • The % check and the removed-part prefixes still look at the whole $ref.

Found in real docs

Celigo's npm specs (Tools, Imports, Exports, APIs, AI Agents, Async Helpers) name their recursive mapping row items. Before this change, Mappings.items.properties.mappings.items became the inlined row, with its nested mappings.items set to {}.

A new symbolic oracle found it: json-schema-diff, with recursive schemas unrolled 3 levels. It flagged those components, and after this change it flags none.

Repro:

downgradeSpecV31ToV30({ openapi: '3.1.0', info, paths: {}, components: { schemas: {
  items: { type: 'object', properties: { kids: { type: 'array', items: { $ref: '#/components/schemas/items' } } } },
} } })
// before: items.properties.kids.items = { type: 'object', properties: { kids: { type: 'array', items: {} } } }
// after:  items.properties.kids.items = { $ref: '#/components/schemas/items' }

Tests

  • New test: components named items, additionalProperties, $defs and definitions, plus a response named items, all keep their $ref. A $ref with a keyword after the name is still replaced. The test fails on main.
  • Suite: all 309 downgrader tests pass, and branch coverage of src stays at 100%.
  • Checks: pnpm run check passes.

Generated by Claude Code

claude added 14 commits November 2, 2026 08:51
3.1 has no default for a missing or unmapped discriminating value, so tools
following the kept discriminator (Hey API types and Zod) pin the default
schema's tag to its name and reject values 3.2 routes to it.

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

3.1 lets a Discriminator Object carry specification extensions, 3.0 does
not, so an `x-` key there made the 3.0 output invalid. Microsoft.OpenApi's
3.0 reader reports it as an error, for example on the
`x-discriminator-default` that SwaggerHub's NPP and APDS specs write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs
…m tag with a default required

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs
…he tag default

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs
…lue enum tag

Hey API pins each discriminator branch to its tag when the tag is a
one-value enum, as a const becomes, so its runtime plugins reject a
value that leaves out a tag with a default (Pydantic's Literal default).
From 3.1's const it uses the branch as written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVaw7PbWyR4kZ18CjgHzqs
…r 3.0 rules

Python's openapi-schema-validator and jsonschema-rs with draft 4 rules
reject a number with a zero fraction for type integer, as 3.0.3 defined
an integer; 3.0.4, which the output names, accepts it as 3.1 does.

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

Hey API 0.99 drops a `{ type: "object" }` branch with nothing else in it from an
`anyOf` in a schema that holds a readOnly or writeOnly property, so its types,
Zod and Valibot reject an object for a `type` list with "object" in 3.0.

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

A $ref into `items`, `additionalProperties`, `$defs` or `definitions` is
replaced by its target, but the check read the whole pointer, so a
component named one of those words, as Celigo names a recursive mapping
row `items`, was inlined at every use and its recursion cut to {} one
level down. The segment after `#/components/<type>/` is a name, so only
the part after it is checked now.

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

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

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 29 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/project-thread-ttohne (8ed8ba0) with main (f5c6835)

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.

✅ No new issues found.

Reviewed changes

  • isInlined strips the component prefix — packages/downgrader/src/v3.1-to-v3.0.ts:298 now removes #/components/<type>/<name> before testing for /$defs/, /definitions/, and INTO_REPLACED, so a $ref to a component named items or additionalProperties is no longer mistaken for a $ref into a keyword. The % check and the #/webhooks/ / #/components/pathItems/ prefixes still read the whole ref, as documented.
  • Regression test — packages/downgrader/tests/v3.1-to-v3.0.test.ts:2935 pins component names items, additionalProperties, $defs, definitions, a response named items, and that a keyword after the name (items/properties/rows/items) is still inlined.

I verified locally: all 309 downgrader tests pass, v3.1-to-v3.0.ts branch coverage is unaffected for the new code (the only uncovered branch, line 1718, is in convertSecurity and pre-existing), and pnpm run check (sherif, oxlint, oxfmt, tsc) passes.

The only behavior narrowed is a $ref pointing directly at a $defs/definitions map under a component whose name is itself $defs/definitions — not a valid schema reference, and refs into removed keywords dangling is already a documented limitation.

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

@dinwwwh
dinwwwh merged commit bf7f59c into main Nov 3, 2026
9 checks passed
dinwwwh added a commit that referenced this pull request Nov 4, 2026
…t type stays as written (#375)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01VVaw7RYWCsn8myBE4Rncuq

Test-only follow-up to #374.

Stryker on #374's `isInlined` lines (`v3.1-to-v3.0.ts:298-325`) left 4
survivors. One is the known equivalent `name === 0` -> `false`,
unchanged since #370. The other three are test gaps:

- `'#/components/schemas/'` -> `''`: the renamed-key check would then
apply to every component type, so a schema `$ref` to
`#/components/parameters/Query/schema` (the key `schema` is no Schema
keyword) would be inlined. No test kept it as written.
- `next === -1` -> `false`, and `-1` -> `+1`: when the ref ends at the
key, the key loses its last letter, so
`#/components/schemas/Negated/not` reads `no`, counts as renamed and is
inlined. No test held a `$ref` that ends at a key 3.0 keeps.

This adds both cases to the "replaces a $ref into a key that becomes an
extension" test. Each must stay a bare `$ref` (`toStrictEqual`).

- `pnpm exec vitest run packages/downgrader`: 311 passed
- Stryker on the same lines with this test: 68 of 69 killed (was 65);
the one left is the known equivalent
- `pnpm run check` passes

Separately, refreshing the 72 fuzz dumps on defcf13 changed 0 outputs.

---
_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