Skip to content

fix: keep sibling keywords when a referenced schema has a root-level $ref - #2965

Open
RomanHotsiy wants to merge 21 commits into
mainfrom
fix/bundle-root-ref-sibling-keywords
Open

fix: keep sibling keywords when a referenced schema has a root-level $ref#2965
RomanHotsiy wants to merge 21 commits into
mainfrom
fix/bundle-root-ref-sibling-keywords

Conversation

@RomanHotsiy

@RomanHotsiy RomanHotsiy commented Jul 24, 2026

Copy link
Copy Markdown
Member

What/Why/How?

bundle dropped the sibling keywords of a root-level $ref when the referenced schema itself started with a $ref — for example an external schema file that composes another file ($ref: ./BaseProblem.yaml plus title/type/properties/required).
The bundled output collapsed such schemas to a bare $ref pointing at the end of the chain, silently losing the sibling constraints.
In OpenAPI 3.1, Schema Objects follow JSON Schema draft 2020-12, where $ref with siblings is a composition, not a transparent alias.

Root cause: followRef in resolve.ts chased every chained $ref to the end of the chain, discarding the intermediate nodes together with their sibling keys.

The fix — resolution results now carry the composition as an additive chain field, so ctx.resolve() keeps returning the chain-end node exactly as before and nothing changes for existing rules, decorators, or plugins:

  • resolve.tsfollowRef still chases to the end of the chain, but records every composed $ref it passes through as a chain hop (ResolvedRefChainHop) on the resolve result.
  • walk.ts — the walker walks each chain hop as a ref node from its own location, so hop siblings and their inner $refs are processed; ResolveResult exposes the chain (with Locations) to visitors.
  • bundle-visitor.ts — a chained ref is rewritten to point at the first composed hop (saved as a component with siblings intact) instead of the chain end; the hop's own $ref is rewritten by the walker pass. Discriminator mappings and component-name dedup use the same effective target.
  • no-required-schema-properties-undefined.ts — the one rule that needs the composition: it validates required on chain hops (they are never visited as Schema nodes) and follows chains and sibling properties/allOf/anyOf/oneOf when looking up required properties.
  • rules/utils.tsresolveSchema passes the chain through for chain-aware rules.

Because resolve() behavior is unchanged for consumers, the rule regressions found on the earlier revision of this PR (array-parameter-serialization, struct, discriminator checks) cannot occur — verified explicitly for each.
The remove-unused-components decorators are chain-safe by construction (they build their usage graph from raw $ref pointers), covered by a new test.

Composed schemas are now preserved as components with their sibling keywords intact, references keep pointing at the composed schema, and --dereferenced merges the composition in chain order.

Reference

Fixes #2964

Testing

  • Unit tests (written to fail first on the broken behavior): bundle tests for external-file and internal component chains plus --remove-unused-components, a resolver test asserting the recorded chain, and regression tests for struct, no-required-schema-properties-undefined (sibling-defined, chain-defined, allOf-sibling-defined, and genuinely-missing required properties), and no-unused-components.
  • Two e2e bundle tests: sibling-refs-root-external-file (fails on unfixed code) and sibling-refs-root-in-file.
  • Full unit suite (2031 tests) and e2e suite pass; no existing snapshots changed. Lint warning count unchanged.
  • Verified by hand: bundle, bundle --dereferenced, lint on the issue repro, and the rule-regression repros from the review.

Check yourself

  • This PR follows the contributing guide
  • All new/updated code is covered by tests
  • Core code changed? - Tested with other Redocly products (internal contributions only)
  • New package installed? - Tested in different environments (browser/node)
  • Documentation update has been considered

Security

  • The security impact of the change has been considered
  • Code follows company security practices and guidelines

🤖 Generated with Claude Code


Note

Medium Risk
Changes core ref resolution, document walking, and bundling behavior used by lint and CLI; risk is mitigated by additive chain API, broad tests, and unchanged chain-end node for most consumers.

Overview
Fixes bundle dropping sibling schema keywords (title, properties, required, etc.) when a resolved schema is itself a $ref plus siblings (OpenAPI 3.1 / JSON Schema 2020-12 composition), including multi-hop and external-file cases.

Resolution still follows refs to the chain end, but now records each composed hop on an additive chain field (ResolvedRefChainHop). walk.ts walks those hops and $ref siblings from the correct locations; bundle-visitor.ts saves/rewrites components using the first composed hop (so siblings survive) and applies the same logic to discriminator mappings; replaceRef handles dereference when a hop has no parent.

Lint: no-required-schema-properties-undefined validates required on composed refs and chain hops; resolveSchema exposes chain. Unit, rule, and e2e bundle tests cover collisions, null targets, unused components, and --dereferenced.

Reviewed by Cursor Bugbot for commit b704b0b. Bugbot is set up for automated code reviews on this repo. Configure here.

@changeset-bot

changeset-bot Bot commented Jul 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b704b0b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@redocly/openapi-core Minor
@redocly/cli Minor
@redocly/respect-core Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 71.89% (🎯 69%) 8712 / 12117
🔵 Statements 71.58% (🎯 69%) 9064 / 12661
🔵 Functions 76.14% (🎯 73%) 1711 / 2247
🔵 Branches 63.49% (🎯 61%) 5889 / 9275
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/core/src/ref-utils.ts 92.85% 95.83% 100% 94.87% 10, 54, 140
packages/core/src/resolve.ts 95.5% 95.07% 100% 95.37% 95, 142, 298, 484-492
packages/core/src/walk.ts 98.23% 97.35% 85% 98.74% 220, 235, 463
packages/core/src/bundle/bundle-visitor.ts 73.64% 70.37% 100% 73.64% 38, 42-50, 61-65, 72, 81, 89, 94-119, 198-210, 227-228, 246-247, 265-266, 312
packages/core/src/rules/utils.ts 92.68% 89.7% 100% 91.78% 72, 106, 116-120, 136, 147-151, 253
packages/core/src/rules/common/no-required-schema-properties-undefined.ts 96.92% 94.91% 100% 96.55% 51, 87
Generated in workflow #10910 for commit b704b0b by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Performance Benchmark (Lower is Faster)

CLI Version Bundle Lint Check Config
cli-latest ▓▓▓ 1.09x ± 0.01 ▓▓ 1.04x ± 0.01 ▓▓ 1.04x ± 0.01
cli-next ▓ 1.00x (Fastest) ▓ 1.00x (Fastest) ▓ 1.00x (Fastest)

Comment thread .changeset/root-ref-sibling-keywords.md Outdated
@RomanHotsiy
RomanHotsiy marked this pull request as ready for review July 24, 2026 07:28
@RomanHotsiy
RomanHotsiy requested review from a team as code owners July 24, 2026 07:28
Comment thread packages/core/src/rules/common/no-required-schema-properties-undefined.ts Outdated

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

lgtm

Comment thread .changeset/root-ref-sibling-keywords.md Outdated
Comment thread packages/core/src/rules/common/no-required-schema-properties-undefined.ts Outdated
Comment thread packages/core/src/resolve.ts Outdated
Comment thread packages/core/src/walk.ts
Comment thread packages/core/src/walk.ts Outdated
Comment thread packages/core/src/resolve.ts
Comment thread packages/core/src/bundle/bundle-visitor.ts Outdated
Comment thread packages/core/src/bundle/bundle-visitor.ts
Comment thread tests/e2e/bundle/sibling-refs-root-in-file/openapi.yaml
Comment thread packages/core/src/walk.ts Outdated
Comment thread packages/core/src/walk.ts Outdated
Comment thread packages/core/src/resolve.ts
Comment thread packages/core/src/walk.ts Outdated
Comment thread .changeset/root-ref-sibling-keywords.md Outdated
@tatomyr

tatomyr commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator
image

Nice performance gain. Do you know the reason?

@RomanHotsiy

Copy link
Copy Markdown
Member Author

Verified locally and bisected it — the gain is real and comes from one commit: 094ff48, the sibling-collision fix in walk.ts. Bundling Rebilly's api-definitions: main 645.9ms ± 3.8 → after the chain rework (2cb6857) 651.0ms ± 5.1 (no change) → after the props-loop restructure (094ff48) 578.1ms ± 3.1, HEAD 573.7ms ± 2.9 (~11%, matching the 1.10x here). The mechanism: the old loop ran the scalar-sibling special case — isRef(node[propName]) plus a fallback property read — for every property of every object node in the document. The restructured loop gates all $ref-sibling handling behind nodeIsRef, which is false for almost every node, so those per-property checks dropped off the hot path entirely. So the correctness fix for colliding sibling keys happened to also delete the walker's most-executed redundant check.

🤖 Addressed by Claude Code

Comment thread packages/core/src/walk.ts
Comment thread packages/core/src/bundle/bundle-visitor.ts
Comment thread packages/core/src/walk.ts
| Record<string, any>;

// A composed $ref (with sibling keys) the resolution chased through on the way to `node`.
export type ResolvedChainHop = ResolvedRefChainHop;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need new type? Let's get rid of it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree (to trigger the update)

Comment thread packages/core/src/walk.ts
continue;
}
if (nodeIsRef && resolvedChain?.length) {
// the target composes another $ref: the hop is walked as a ref node from its own

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make those comments simpler to understand

Comment thread packages/core/src/walk.ts

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6fbd859. Configure here.

Comment thread packages/core/src/walk.ts
@RomanHotsiy

Copy link
Copy Markdown
Member Author

Okay, now it's ready to reveiw.

@RomanHotsiy
RomanHotsiy requested review from tatomyr and vadyvas July 28, 2026 08:57

@vadyvas vadyvas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One stylistic thing, non-blocking: the new code more comments than these files usually are, and a few of the comments are hard to read in themselves

return true;
if (isRef(node)) {
const resolved = ctx.resolve(node, rootLocation.absolutePointer);
const effectiveLocation = resolved.chain?.[0]?.location ?? resolved.location;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think creating a utility or helper could improve readability in several places

const effectiveTarget = (resolved: ResolveResult<NonUndefined>): ComponentTarget =>
  resolved.chain?.[0] ?? resolved;

Comment thread packages/core/src/walk.ts
} = resolve(node);
const enteredContexts: Set<VisitorLevelContext> = new Set();

if (nodeIsRef && Object.keys(node).length > 1) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think creating a util in ref-utils.ts could improve readability

export function isRefWithSiblings(node: unknown): node is OasRef & Record<string, unknown> {
  return isRef(node) && Object.keys(node).length > 1;
}

});

expect(problems).toHaveLength(0);
expect((res.parsed as any).components.schemas.BadRequest).toEqual({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wouldn't it be better to use snapshot check?

// by dropping the $ref and keeping the sibling keys
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete ref.$ref;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wdyt

export function replaceRef(ref: OasRef, resolved: ResolveResult<any>, ctx: UserContext) {
  if (!isPlainObject(resolved.node) && ctx.parent) {
    ctx.parent[ctx.key] = resolved.node;
    return;
  }

  delete (ref as Partial<OasRef>).$ref;

  if (isPlainObject(resolved.node)) {
    Object.assign(ref, { ...resolved.node, ...ref });
  }
}

Comment thread packages/core/src/walk.ts
| Record<string, any>;

// A composed $ref (with sibling keys) the resolution chased through on the way to `node`.
export type ResolvedChainHop = ResolvedRefChainHop;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree (to trigger the update)

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.

bundle drops sibling keywords when an external schema file has a root-level $ref

6 participants