feat: support $dynamicRef / $dynamicAnchor (JSON Schema 2020-12) #337
Merged
aeschli merged 8 commits intoJul 12, 2026
Merged
Conversation
added 3 commits
July 6, 2026 14:36
…r (2020-12) Implement the first half of JSON Schema 2020-12 $dynamicRef/$dynamicAnchor support: - Register $dynamicAnchor names as ordinary anchors so a reference can resolve to them, exactly like $anchor. - Resolve $dynamicRef to its initial (lexically-static) target and validate the instance against it, so $dynamicRef behaves like a plain $ref to the location it initially points at. The target is kept as hidden, non-enumerable metadata ($dynamicRefTarget) so sibling keywords on the reference are preserved. - Remove the previous 'unsupported feature' warning for $dynamicRef/ $dynamicAnchor and update the corresponding tests. True dynamic-scope (bookended) resolution is added in a follow-up commit; the 7 official test-suite cases that require it are temporarily skipped.
Complete JSON Schema 2020-12 $dynamicRef support with true dynamic-scope
("bookended") resolution:
- Add a pre-pass (collectDynamicAnchors) that, from the original schema tree
before $ref merging flattens resource boundaries, builds per-resource anchor
maps ($anchorMaps: dynamic-only and all-anchor) and records each $dynamicRef's
lexical resource (in $dynamicRefInfo.scope).
- Resolve an internal $dynamicRef's initial target within its own lexical
resource so a sibling resource's identically-named anchor cannot leak in.
- At validation time, apply the bookending rule: when the initial target is a
$dynamicAnchor of the referenced name, resolve to the outermost matching
$dynamicAnchor currently in the dynamic scope; otherwise behave like a plain
$ref.
- Apply $dynamicRef alongside any sibling keywords on the same node, rather than
treating it as a redirect that replaces them.
- Propagate the hidden $dynamicRef metadata through schema merging so a
$dynamicRef living directly on a $ref'd resource still resolves.
- Ignore $dynamicRef as an unknown keyword in drafts before 2020-12.
- Store the internal, non-enumerable metadata in two consolidated records
(DynamicRefInfo and AnchorMaps) rather than five separate hidden fields.
All 32 official dynamicRef test-suite cases now pass; the temporary skips added
in the previous commit are removed.
Add project-owned tests for the gaps found in the $dynamicRef coverage review, so the behavior no longer relies solely on the vendored official suite: - a sub-schema shared by reference is anchor-collected once (shared-node guard) and raises no spurious duplicate-anchor error - a duplicate $dynamicAnchor within one resource is reported as a schema resolution error - genuine dynamic-scope resolution: an outer $dynamicAnchor overrides a base resource's default (extendible pattern) - a $dynamicRef to a plain $anchor (not bookended) behaves like a plain $ref - a $dynamicRef with a JSON-pointer fragment behaves like a plain $ref - a $dynamicRef resolved through an external schema - $dynamicRef is ignored as an unknown keyword in draft 2019-09 - completion and hover resolve through a $dynamicRef node
Member
Author
|
FYI @jdesrosiers and @aeschli |
Member
|
Cc @handrews and @karenetheridge |
…-12) The dynamic-scope pre-pass (collectDynamicAnchors) ran only over the root document's resources, so a $dynamicAnchor that overrides a base default was honored only when it lived in the document being validated. When the extending resource lived in a *separate* document reached via $ref, its $dynamicAnchor never joined the dynamic scope and the override was lost. - Collect each referenced document's own anchor maps (once) during external link resolution, before $ref merging flattens resource boundaries, so its $dynamicAnchor declarations can participate in the dynamic scope and its $dynamicRef nodes get their lexical resource recorded. - Fold a merged section's anchor maps into the target during merge, with outermost-resource-wins precedence, so a $dynamicAnchor defined in an external (or $ref'd sub-) resource can override a base default while the base's own entries keep priority. - Derive the effective draft from each resource's own $schema so a referenced pre-2020-12 document is not given $dynamicAnchor semantics. Tests cover the cross-document override for both an internal-fragment ($dynamicRef "#name") and a non-fragment URI ($dynamicRef "other#name") starting point. Each case uses a fresh service and a fresh schema object: in-place $ref resolution mutates the schema it is given, so reusing one object would let a prior validation leak anchor state and mask the gap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Bump the vendored JSON-Schema-Test-Suite from 69acf52 to 92acb61 and fix
the spec-compliance gaps it surfaced. The full suite now passes with no
skipped assertions.
- $recursiveRef no longer early-returns, so sibling unevaluatedItems /
unevaluatedProperties still evaluate against the node and accumulate
their processed-property annotations.
- Resolve $defs/definitions before if/then/else/items during traversal so
a referenced resource finishes assembling before a referrer merges it.
Fixes a $dynamicRef chain reached through then: { $ref: ... } capturing
a half-resolved snapshot.
- Isolate into an allOf (instead of flatten-merging) when both schemas
constrain array items, or when a referencing schema's unevaluatedItems
would otherwise capture a referenced schema's positional items and its
internal self-references.
- Keep $recursiveAnchor/$dynamicAnchor and unevaluated* on the allOf
wrapper so bookending targets stay discoverable and unevaluated* observes
the aggregated annotations of every in-place applicator.
- Treat a boolean subschema (true/false) as a real schema when following a
$ref, not as a missing section.
- Register a referenced document's embedded $id resources as resolvable
handles, and join a fragment-referenced resource's $dynamicAnchor names
to the referencing dynamic scope.
- Fold only the dynamic anchor map across resources; the local map stays
per-resource so a sibling's identically-named anchor cannot leak into an
internal $dynamicRef's lexical resolution.
aeschli
previously approved these changes
Jul 11, 2026
aeschli
enabled auto-merge (squash)
July 11, 2026 09:19
lszomoru
previously approved these changes
Jul 11, 2026
TylerLeonhardt
previously approved these changes
Jul 11, 2026
vijayupadya
approved these changes
Jul 11, 2026
vijayupadya
previously approved these changes
Jul 11, 2026
aeschli
enabled auto-merge (squash)
July 12, 2026 15:10
chrmarti
approved these changes
Jul 12, 2026
aeschli
approved these changes
Jul 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The simple case: A
$dynamicAnchoris registered as a normal anchor, the same way$anchoris, so references can find it. A$dynamicRefresolves to wherever it points lexically. For many schemas that's all you need, and it behaves like a plain$ref. This is the first commit.The second part is dynamic resolution. To resolve dynamically, you need to know which anchors belong to which schema resource. The problem is that
$refmerging flattens those resource boundaries before we get a chance to look. So, we run a pre-pass (collectDynamicAnchors) over the original tree first. It records, for each resource, which anchors it has (the dynamic ones and all of them), and which resource each$dynamicRefwas written in. A$dynamicRefresolves its first target inside its own resource, so an anchor with the same name in a sibling resource can't be picked up by mistake.At validation time the rule is this: if the first target is a
$dynamicAnchorwith the right name, we look through the current dynamic scope from the outside in and use the outermost match. If it isn't, the reference stays where it is and acts like a plain$ref. In both cases the$dynamicRefis applied together with any sibling keywords on the same node, not in place of them.That pre-pass has a limit worth calling out: it runs over the document being validated. Within a single document the dynamic scope is solid, and that's most of what you'll hit. The weak spot is bookending that has to reach into a separate document. A
$dynamicAnchorthat overrides a base default can live in another document reached by$ref, and there it never joined the scope, so the override was lost. The reference still found its starting point the normal way — it just didn't extend the scope across the document boundary the way the root does. The fix is to collect a referenced document's anchor maps too, once, while the external link is resolved (again, before merging flattens the boundaries), and fold them in with the same outermost-wins rule. Each resource's draft is read from its own$schema, so pulling in a pre-2020-12 document doesn't hand it$dynamicAnchorsemantics.Updating the vendored test suite to current main turned up a handful of related reference-scope bugs, in 2019-09 as much as 2020-12. None are in
$dynamicRefitself — they're places where$refor$recursiveRefmerging was too eager or quietly dropped something:• A
$recursiveRefstopped after validating its target, so a siblingunevaluatedItemsorunevaluatedPropertieson the same node never got a turn. It now falls through, and they see the annotations the reference produced.• When the referenced and referencing schemas, both constrain array items, a flatten-merge could only keep one of them. Those cases isolate into an
allOfso both apply — and the anchor andunevaluated*keywords stay on the wrapper, so bookending targets stay findable andunevaluated*still sees every applicator's results.• A boolean subschema —
trueorfalse— is a real schema, not a missing section, when a$refpoints at one.• A referenced document's own embedded
$idresources are registered as targets, so a nested$refby that id resolves to them instead of being fetched again as a fresh document.• Only the dynamic anchor map is folded across resources; the per-resource map stays put, so a sibling's identically named anchor can't leak into a
$dynamicRef's lexical lookup.Two smaller points:
• The internal data (the per-resource anchor maps, and each ref's first target and scope) is stored in non-enumerable fields so it doesn't show up in serialized schemas. It's kept in two records,
DynamicRefInfoon the reference andAnchorMapson the resource root, rather than several separate properties.• In drafts older than 2020-12,
$dynamicRefis treated as an unknown keyword and ignored, so nothing changes for existing schemas.Testing
New project-owned tests cover the scenarios above — the
$dynamicRef/$dynamicAnchoredge cases, the cross-document overrides, and the reference-scope fixes — so none of these leans entirely on the vendored suite. The vendored JSON-Schema-Test-Suite is bumped to current main (92acb61) and runs across draft4/6/7/2019-09/2020-12 with nothing skipped: 5326 tests, all passing.Code coverage
Line: 97.5%
Branch: 93%
Func: 95%