Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
6.0.0-next.2 / 2026-07-11
================
- Support $dynamicRef / $dynamicAnchor (JSON Schema 2020-12)
- Support JSON Schema 2019-09

6.0.0-next.1 / 2026-03-04
================
* Breaking: the package is now ESM-only (`"type": "module"`) and exposes ESM entry points through `exports`.
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"@typescript-eslint/eslint-plugin": "^8.63.0",
"@typescript-eslint/parser": "^8.63.0",
"eslint": "10.6.0",
"json-schema-test-suite": "https://github.com/json-schema-org/JSON-Schema-Test-Suite.git#69acf52990b004240839ae19b4bec8fb01d50876",
"json-schema-test-suite": "https://github.com/json-schema-org/JSON-Schema-Test-Suite.git#92acb61eb772a932c077d5ffa634ded719d2d738",
"rimraf": "^6.1.3",
"typescript": "6.0.x"
},
Expand Down
62 changes: 62 additions & 0 deletions src/jsonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,66 @@ export interface JSONSchemaMap {
*/
export interface MergedJSONSchema extends JSONSchema {
$originalId?: string;

/**
* Non-enumerable metadata attached to a `$dynamicRef` node while resolving it.
* Held off the enumerable keywords so it stays invisible to schema traversal,
* merging and consumers, but available to the validator.
*/
$dynamicRefInfo?: DynamicRefInfo;

/**
* Non-enumerable per-resource anchor maps, attached to resource-root schemas
* (a node with its own `$id`, or the document root) for 2020-12 `$dynamicRef`
* resolution.
*/
$anchorMaps?: AnchorMaps;
}

/**
* Internal, non-enumerable metadata recorded for a single `$dynamicRef` occurrence.
*/
export interface DynamicRefInfo {
/**
* The statically resolved initial target (resolved like a plain `$ref`, against
* the reference's own base URI).
*/
target?: JSONSchema;

/**
* The plain-name fragment of the `$dynamicRef` (set whenever the fragment is a
* plain name rather than a JSON pointer). The validator uses it, together with
* the initial target, to decide at validation time whether the 2020-12
* "bookending" requirement holds (i.e. the initial target is a `$dynamicAnchor`
* of that name) and therefore whether to perform dynamic-scope resolution
* instead of behaving like a plain `$ref`.
*/
name?: string;

/**
* The schema resource (nearest enclosing `$id` scope) that lexically contains
* the `$dynamicRef`. Recorded before `$ref` merging flattens resource
* boundaries so an internal `#name` reference resolves against its own
* resource's anchors, not a sibling's.
*/
scope?: MergedJSONSchema;
}

/**
* Internal, non-enumerable per-resource anchor maps used for 2020-12 `$dynamicRef`
* resolution. Attached to resource-root schemas.
*/
export interface AnchorMaps {
/**
* `$dynamicAnchor` names → sub-schemas within the resource, used by the
* validator to walk the dynamic scope (outermost resource first).
*/
dynamic: Map<string, JSONSchema>;

/**
* Both `$anchor` and `$dynamicAnchor` names → sub-schemas within the resource,
* used to resolve an internal `$dynamicRef`'s initial target within its own
* resource.
*/
local: Map<string, JSONSchema>;
}
38 changes: 36 additions & 2 deletions src/parser/jsonParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,10 +455,11 @@ function validate(n: ASTNode | undefined, schema: JSONSchema, validationResult:
? schemaStack.find(hasRecursiveAnchor)
: currentResourceRoot ?? schemaRoots[0];

// Validate against the target schema (avoiding infinite loop)
// Validate against the target schema (avoiding infinite loop).
// Fall through afterwards so sibling keywords (e.g. unevaluatedProperties)
// are still evaluated against this node, accumulating processedProperties.
if (targetSchema && targetSchema !== schema) {
validate(node, targetSchema, validationResult, matchingSchemas, context, schemaStack, schemaRoots);
return;
}
}

Expand All @@ -471,6 +472,39 @@ function validate(n: ASTNode | undefined, schema: JSONSchema, validationResult:
// Push current schema to stack for $recursiveRef resolution
schemaStack.push(schema);

// 2020-12 $dynamicRef. The initial (static) target and the referenced plain-name
// fragment were recorded during schema resolution in $dynamicRefInfo (target and
// name). "Bookending": dynamic resolution applies only when that
// initial target is itself a $dynamicAnchor of the referenced name; then the
// reference resolves to the outermost matching $dynamicAnchor currently in the
// dynamic scope (schemaRoots, ordered outermost-first). Otherwise it behaves
// like a plain $ref to the initial target. This runs after the schemaRoots push
// so the resource that contains the $dynamicRef participates in its own
// dynamic-scope resolution.
const dynamicRefInfo = (schema as MergedJSONSchema).$dynamicRefInfo;
const dynamicRefTarget = dynamicRefInfo?.target;
if (dynamicRefTarget !== undefined) {
let targetSchema: JSONSchema = dynamicRefTarget;
const dynamicName = dynamicRefInfo?.name;
if (dynamicName !== undefined && (dynamicRefTarget as MergedJSONSchema).$dynamicAnchor === dynamicName) {
for (const root of schemaRoots) {
const candidate = (root as MergedJSONSchema).$anchorMaps?.dynamic.get(dynamicName);
if (candidate) {
targetSchema = candidate;
break;
}
}
}

// Validate the instance against the referenced schema, then fall through so
// that any sibling keywords on this same node are validated too: per 2020-12
// $dynamicRef is an applicator that applies alongside its siblings (like $ref),
// not a redirect that replaces them. The shared cleanup below pops the stack.
if (targetSchema && targetSchema !== schema) {
validate(node, targetSchema, validationResult, matchingSchemas, context, schemaStack, schemaRoots);
}
}

const enabled = (keyword: string) => {
// Draft-based keyword gating: keywords only exist in certain drafts.
// 'dependencies' was replaced by dependentRequired/dependentSchemas in 2019-09
Expand Down
Loading
Loading