Skip to content

fix: keep $ref sibling keywords during getNode traversal (draft 2019-09+)#112

Open
samlown wants to merge 1 commit into
sagold:mainfrom
invopop:upstream-fix/ref-sibling-keywords-getnode
Open

fix: keep $ref sibling keywords during getNode traversal (draft 2019-09+)#112
samlown wants to merge 1 commit into
sagold:mainfrom
invopop:upstream-fix/ref-sibling-keywords-getnode

Conversation

@samlown

@samlown samlown commented Jun 14, 2026

Copy link
Copy Markdown

Summary

In draft 2019-09 and later, $ref is an applicator that combines with its sibling keywords — { "$ref": …, "oneOf": [...] } means "match the target and the oneOf". validate() honours this correctly (each sibling runs as an independent validator), but the getNode traversal path does not.

getNodeChild resolves a child $ref to the bare target via resolveRef(), discarding siblings:

// src/getNodeChild.ts
return { node: schemaNode.resolveRef({ pointer, path }), error: undefined };

(resolveRefcompileNext only carries over settings.PROPERTIES_TO_MERGE — title/description/etc. — never applicator keywords.)

So traversal silently drops the sibling constraint:

const root = compileSchema({
  $schema: "https://json-schema.org/draft/2020-12/schema",
  type: "array",
  items: { $ref: "#/$defs/key", oneOf: [{ const: "a" }, { const: "b" }] },
  $defs: { key: { type: "string" } }
});

root.getNode("/0", [""]);
// before: { node: { type: "string" } }   ← oneOf gone, no error

A plain oneOf (no $ref) already returns the expected one-of-error whose error.data.schema carries the full schema, so consumers that use getNode to enumerate allowed values (autocomplete, data generation) work in that case but not when a $ref is involved.

This appears to be a recent regression: the resolveRef() call was introduced in fix: missing resolveRef in getNodeChild, which resolved child refs (good) but to the bare target (dropping siblings). The official JSON Schema Test Suite only exercises validate(), so no spec test covers this traversal behaviour.

Fix

In getNodeChild, merge the child's sibling keywords onto the resolved target (dropping the now-resolved $ref), gated on whether the draft registers a $ref reducer:

  • draft 2019-09 / 2020-12 register a $ref reducer → siblings are merged (applicator semantics).
  • draft-07 and earlier have no $ref reducer → unchanged, historical "siblings ignored" behaviour preserved.

$ref + oneOf now behaves identically to a plain oneOf at every location:

root.getNode("/0", [""]);
// after: one-of-error, error.data.schema.oneOf === [{const:"a"},{const:"b"}]
root.getNode("/0", ["a"]);
// after: reduces to the matching branch { type:"string", const:"a" }

Tests

  • Full multi-draft spec suite unchanged and green (draft 04/06/07/2019-09/2020-12).
  • Added a $ref sibling keywords group to src/getNode.test.ts covering object-property, array-item, matching-data reduction, and the draft-07 "ignored" case.

Notes

  • Source + tests only; dist/ is not included (regenerate on publish).
  • Happy to adjust the approach — e.g. handling this inside resolveRef/compileNext instead of getNodeChild — but the getNodeChild gate keeps resolveRef returning target-only for validate(), which is what the independent-validator model expects. Open to your preference.

In draft 2019-09 and later, $ref is an applicator that combines with its
sibling keywords (`{$ref, oneOf}` means "match the target AND the oneOf").
Validation already honours this per-draft, but the navigation/reduction
path does not: getNodeChild resolves a child $ref to the bare target via
resolveRef(), discarding siblings (compileNext only carries over the
annotation keywords in settings.PROPERTIES_TO_MERGE).

As a result getNode() on a location like

  { "$ref": "#/$defs/key", "oneOf": [ { "const": "a" }, ... ] }

returns just the resolved target, dropping the oneOf — so consumers that
rely on getNode to enumerate allowed values (e.g. editor autocomplete)
see nothing. A plain oneOf (no $ref) already returns the expected
one-of-error carrying the full schema; this makes $ref + oneOf behave
identically.

Merge the child's sibling keywords onto the resolved target (dropping the
now-resolved $ref), gated on whether the draft registers a $ref reducer.
draft 2019-09/2020-12 register one and merge; draft-07 and earlier do not
and keep the historical "siblings ignored" behaviour.

Verified against the full multi-draft spec suite (8515 passing, unchanged)
plus 4 new $ref-sibling tests in getNode.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <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.

1 participant