From 6f7bcc01d5cd8667898fa1e8ac9bcd9d3de1d4fd Mon Sep 17 00:00:00 2001 From: Keegan Caruso Date: Mon, 6 Jul 2026 14:28:05 -0700 Subject: [PATCH 1/6] feat: resolve $dynamicRef as a static $ref and register $dynamicAnchor (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. --- src/jsonSchema.ts | 10 ++++ src/parser/jsonParser.ts | 15 ++++++ src/services/jsonSchemaService.ts | 76 +++++++++++++++++++++++----- src/test/jsonSchemaTestSuite.test.ts | 20 +++----- src/test/schema.test.ts | 57 ++++++++++----------- 5 files changed, 122 insertions(+), 56 deletions(-) diff --git a/src/jsonSchema.ts b/src/jsonSchema.ts index 149dd795..6b717688 100644 --- a/src/jsonSchema.ts +++ b/src/jsonSchema.ts @@ -105,4 +105,14 @@ export interface JSONSchemaMap { */ export interface MergedJSONSchema extends JSONSchema { $originalId?: string; + + /** + * Non-enumerable metadata attached while resolving a `$dynamicRef`. + * + * `$dynamicRefTarget` is the statically resolved initial target (resolved + * like a plain `$ref`, against the reference's own base URI). The validator + * validates the instance against it, so a `$dynamicRef` behaves like a plain + * `$ref` to the location it initially points at. + */ + $dynamicRefTarget?: JSONSchema; } diff --git a/src/parser/jsonParser.ts b/src/parser/jsonParser.ts index 2b47f2b0..81dfa516 100644 --- a/src/parser/jsonParser.ts +++ b/src/parser/jsonParser.ts @@ -471,6 +471,21 @@ function validate(n: ASTNode | undefined, schema: JSONSchema, validationResult: // Push current schema to stack for $recursiveRef resolution schemaStack.push(schema); + // 2020-12 $dynamicRef, resolved here like a plain $ref. The target was + // recorded during schema resolution as $dynamicRefTarget; validate the + // instance against it. (Dynamic-scope resolution is layered on in a later + // step.) This runs after the schemaRoots push so the resource that contains + // the $dynamicRef participates in its own resolution. + const dynamicRefTarget = (schema as MergedJSONSchema).$dynamicRefTarget; + if (dynamicRefTarget !== undefined && dynamicRefTarget !== schema) { + validate(node, dynamicRefTarget, validationResult, matchingSchemas, context, schemaStack, schemaRoots); + schemaStack.pop(); + if (isNewRoot) { + schemaRoots.pop(); + } + return; + } + const enabled = (keyword: string) => { // Draft-based keyword gating: keywords only exist in certain drafts. // 'dependencies' was replaced by dependentRequired/dependentSchemas in 2019-09 diff --git a/src/services/jsonSchemaService.ts b/src/services/jsonSchemaService.ts index 53e5b561..f3bea259 100644 --- a/src/services/jsonSchemaService.ts +++ b/src/services/jsonSchemaService.ts @@ -505,7 +505,6 @@ export class JSONSchemaService implements IJSONSchemaService { return this.promise.resolve(new ResolvedSchema({}, [toDiagnostic(l10n.t("Draft-03 schemas are not supported."), ErrorCode.SchemaUnsupportedFeature)], [], schemaDraft, undefined)); } - let usesUnsupportedFeatures = new Set(); let activeVocabularies: Vocabularies | undefined = undefined; const extractVocabularies = (metaschema: JSONSchema): Vocabularies | undefined => { @@ -524,6 +523,18 @@ export class JSONSchemaService implements IJSONSchemaService { const contextService = this.contextService; + // Attach internal, non-enumerable metadata to a schema node. Hidden so it is + // invisible to schema traversal, merging and consumers, but available to the + // validator (e.g. for $recursiveRef/$dynamicRef resolution). + const setHidden = (obj: any, key: string, value: any): void => { + Object.defineProperty(obj, key, { + value, + enumerable: false, + writable: true, + configurable: true + }); + }; + const findSectionByJSONPointer = (schema: JSONSchema, path: string): any => { path = decodeURIComponent(path); let current: any = schema; @@ -605,6 +616,18 @@ export class JSONSchemaService implements IJSONSchemaService { configurable: true }); } + + // Propagate hidden $dynamicRef metadata. When a $dynamicRef lives directly on + // a $ref'd schema resource, that resource is a distinct object from the + // referencing schema and is often resolved standalone first — deleting its + // $dynamicRef and recording the (non-enumerable, so not copied above) target. + // Carry it over so the merged schema still resolves the reference. (When the + // $dynamicRef lives on a child of the merged resource, that child is shared by + // reference and already carries its own metadata.) + const src = section as MergedJSONSchema; + if (src.$dynamicRefTarget !== undefined && (target as MergedJSONSchema).$dynamicRefTarget === undefined) { + setHidden(target, '$dynamicRefTarget', src.$dynamicRefTarget); + } }; type SchemaKeyword = keyof JSONSchema; @@ -842,10 +865,30 @@ export class JSONSchemaService implements IJSONSchemaService { } if (schema.$dynamicRef) { - usesUnsupportedFeatures.add('$dynamicRef'); - } - if (schema.$dynamicAnchor) { - usesUnsupportedFeatures.add('$dynamicAnchor'); + // 2020-12 $dynamicRef, resolved here as a plain $ref: its target is + // resolved statically (against the reference's own base URI) and kept + // as hidden metadata ($dynamicRefTarget) instead of being merged into + // `schema`, so `schema`'s own sibling keywords are preserved. The + // validator then validates the instance against that target. (True + // dynamic-scope resolution is layered on top of this in a later step.) + const ref = schema.$dynamicRef; + const segments = ref.split('#', 2); + delete schema.$dynamicRef; + + if (segments[0].length > 0) { + // External / relative reference: resolve the target document into a + // throwaway container so `schema`'s own keywords are preserved. + const target: JSONSchema = {}; + setHidden(schema, '$dynamicRefTarget', target); + const refBase = (newBase === schema) ? currentBaseHandle : newBaseHandle; + openPromises.push(resolveExternalLink(target, segments[0], segments[1], refBase)); + } else { + // Internal reference: resolve like a plain $ref against the current + // base into a throwaway container. + const container: JSONSchema = {}; + mergeRef(container, newBase, newBaseHandle, segments[1]); + setHidden(schema, '$dynamicRefTarget', container); + } } // Continue traversing child schemas with the potentially updated base @@ -881,16 +924,23 @@ export class JSONSchemaService implements IJSONSchemaService { // Collect anchor from this node // In draft-04/06/07, anchors are defined via $id/#fragment (e.g., "$id": "#myanchor") // In 2019-09+, $id fragments are no longer anchors; $anchor is used instead + // In 2020-12, $dynamicAnchor also defines a plain-name fragment that a + // (static) $ref can resolve to, just like $anchor. const fragmentAnchor = (draft === undefined || draft < SchemaDraft.v2019_09) && isString(id) && id.charAt(0) === '#' ? id.substring(1) : undefined; const dollarAnchor = (draft === undefined || draft >= SchemaDraft.v2019_09) ? node.$anchor : undefined; - const anchor = fragmentAnchor ?? dollarAnchor; - if (anchor) { - if (result.has(anchor)) { + const dynamicAnchor = (draft === undefined || draft >= SchemaDraft.v2020_12) ? node.$dynamicAnchor : undefined; + const registerAnchor = (anchor: string | undefined) => { + if (!anchor) { + return; + } + if (result.has(anchor) && result.get(anchor) !== node) { resolveErrors.push(toDiagnostic(l10n.t('Duplicate anchor declaration: \'{0}\'', anchor), ErrorCode.SchemaResolveError)); } else { result.set(anchor, node); } - } + }; + registerAnchor(fragmentAnchor ?? dollarAnchor); + registerAnchor(dynamicAnchor); // Continue traversing child schemas JSONSchemaService.traverseSchemaProperties(node, (childSchema) => { @@ -952,6 +1002,8 @@ export class JSONSchemaService implements IJSONSchemaService { // Collect anchors eagerly before $ref resolution mutates the schema. // resolveRefs merges referenced nodes (including $anchor) into $ref targets, // so a lazy collectAnchors call could see duplicates from merged copies. + // $dynamicAnchor names are registered as ordinary anchors here (see + // collectAnchors), so a $dynamicRef resolves to them like a plain $ref. handle.anchors = collectAnchors(schema); // Resolve meta-schema to extract vocabularies if present @@ -986,11 +1038,7 @@ export class JSONSchemaService implements IJSONSchemaService { return resolveMetaschemaVocabularies().then(() => { return resolveRefs(schema, schema, handle).then(_ => { - let resolveWarnings: SchemaDiagnostic[] = []; - if (usesUnsupportedFeatures.size) { - resolveWarnings.push(toDiagnostic(l10n.t('The schema uses meta-schema features ({0}) that are not yet supported by the validator.', Array.from(usesUnsupportedFeatures.keys()).join(', ')), ErrorCode.SchemaUnsupportedFeature)); - } - return new ResolvedSchema(schema, resolveErrors, resolveWarnings, schemaDraft, activeVocabularies); + return new ResolvedSchema(schema, resolveErrors, [], schemaDraft, activeVocabularies); }); }); }; diff --git a/src/test/jsonSchemaTestSuite.test.ts b/src/test/jsonSchemaTestSuite.test.ts index 9947181f..39abfa6e 100644 --- a/src/test/jsonSchemaTestSuite.test.ts +++ b/src/test/jsonSchemaTestSuite.test.ts @@ -198,22 +198,16 @@ function initializeTests() { }); } -const skippedTests = new Set([ - "draft2020-12/dynamicRef.json/A $dynamicRef to a $dynamicAnchor in the same schema resource should behave like a normal $ref to an $anchor/An array of strings is valid", - "draft2020-12/dynamicRef.json/A $dynamicRef to an $anchor in the same schema resource should behave like a normal $ref to an $anchor/An array of strings is valid", - "draft2020-12/dynamicRef.json/A $ref to a $dynamicAnchor in the same schema resource should behave like a normal $ref to an $anchor/An array of strings is valid", - "draft2020-12/dynamicRef.json/A $dynamicRef should resolve to the first $dynamicAnchor still in scope that is encountered when the schema is evaluated/An array of strings is valid", - "draft2020-12/dynamicRef.json/A $dynamicRef with intermediate scopes that don't include a matching $dynamicAnchor should not affect dynamic scope resolution/An array of strings is valid", +const skippedTests = new Set([ + // These require true dynamic-scope resolution of $dynamicRef, which is added in a + // follow-up change. With the static ($ref-like) fallback they resolve to the wrong + // (lexically-initial) target, so they are skipped until dynamic resolution lands. "draft2020-12/dynamicRef.json/An $anchor with the same name as a $dynamicAnchor should not be used for dynamic scope resolution/Any array is valid", "draft2020-12/dynamicRef.json/A $dynamicRef without a matching $dynamicAnchor in the same schema resource should behave like a normal $ref to $anchor/Any array is valid", "draft2020-12/dynamicRef.json/A $dynamicRef with a non-matching $dynamicAnchor in the same schema resource should behave like a normal $ref to $anchor/Any array is valid", - "draft2020-12/dynamicRef.json/A $dynamicRef that initially resolves to a schema with a matching $dynamicAnchor should resolve to the first $dynamicAnchor in the dynamic scope/The recursive part is valid against the root", - "draft2020-12/dynamicRef.json/A $dynamicRef that initially resolves to a schema without a matching $dynamicAnchor should behave like a normal $ref to $anchor/The recursive part doesn't need to validate against the root", - "draft2020-12/dynamicRef.json/multiple dynamic paths to the $dynamicRef keyword/recurse to anyLeafNode - floats are allowed", + "draft2020-12/dynamicRef.json/A $dynamicRef that initially resolves to a schema with a matching $dynamicAnchor should resolve to the first $dynamicAnchor in the dynamic scope/The recursive part is not valid against the root", + "draft2020-12/dynamicRef.json/multiple dynamic paths to the $dynamicRef keyword/recurse to integerNode - floats are not allowed", + "draft2020-12/dynamicRef.json/after leaving a dynamic scope, it should not be used by a $dynamicRef/string matches /$defs/thingy, but the $dynamicRef does not stop here", "draft2020-12/dynamicRef.json/after leaving a dynamic scope, it should not be used by a $dynamicRef//then/$defs/thingy is the final stop for the $dynamicRef", - "draft2020-12/dynamicRef.json/strict-tree schema, guards against misspelled properties/instance with correct field", - "draft2020-12/dynamicRef.json/tests for implementation dynamic anchor and reference link/correct extended schema", - "draft2020-12/dynamicRef.json/Tests for implementation dynamic anchor and reference link. Reference should be independent of any possible ordering./correct extended schema", - "draft2020-12/dynamicRef.json/Tests for implementation dynamic anchor and reference link. Reference should be independent of any possible ordering./correct extended schema", ]); initializeTests(); \ No newline at end of file diff --git a/src/test/schema.test.ts b/src/test/schema.test.ts index 83819cbe..cb3d7755 100644 --- a/src/test/schema.test.ts +++ b/src/test/schema.test.ts @@ -2389,7 +2389,7 @@ suite('JSON Schema', () => { assert.strictEqual(validation.length, 0); }); - test('schema with $dynamicAnchor shows unsupported feature warning', async function () { + test('schema with $dynamicAnchor is supported', async function () { const schema: JSONSchema = { $schema: 'https://json-schema.org/draft/2020-12/schema', $dynamicAnchor: 'items', @@ -2401,55 +2401,54 @@ suite('JSON Schema', () => { const { textDoc, jsonDoc } = toDocument(JSON.stringify({ name: 'test' })); const validation = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); - assert.strictEqual(validation.length, 1); - assert.ok(validation[0].message.includes('$dynamicAnchor')); - assert.ok(validation[0].message.includes('not yet supported')); - assert.strictEqual(validation[0].severity, DiagnosticSeverity.Warning); - assert.strictEqual(validation[0].code, ErrorCode.SchemaUnsupportedFeature); + assert.strictEqual(validation.length, 0); }); - test('schema with $dynamicRef shows unsupported feature warning', async function () { + test('schema with $dynamicRef is supported', async function () { const schema: JSONSchema = { $schema: 'https://json-schema.org/draft/2020-12/schema', - $dynamicRef: '#items', - type: 'object' + type: 'array', + items: { $dynamicRef: '#node' }, + $defs: { + node: { $dynamicAnchor: 'node', type: 'string' } + } }; const ls = getLanguageService({}); - const { textDoc, jsonDoc } = toDocument(JSON.stringify({ name: 'test' })); - const validation = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); - - assert.strictEqual(validation.length, 1); - assert.ok(validation[0].message.includes('$dynamicRef')); - assert.ok(validation[0].message.includes('not yet supported')); - assert.strictEqual(validation[0].severity, DiagnosticSeverity.Warning); - assert.strictEqual(validation[0].code, ErrorCode.SchemaUnsupportedFeature); + // Valid: every item resolves through $dynamicRef to the string schema + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify(['a', 'b'])); + const validation = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); + assert.strictEqual(validation.length, 0); + } + // Invalid: a non-string item violates the resolved schema + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify(['a', 1])); + const validation = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); + assert.ok(validation.length > 0); + } }); - test('schema with multiple unsupported features shows warning with all features', async function () { + test('schema with $dynamicRef and $dynamicAnchor together is supported', async function () { const schema: JSONSchema = { $schema: 'https://json-schema.org/draft/2020-12/schema', $vocabulary: { 'https://json-schema.org/draft/2020-12/vocab/core': true }, - $dynamicAnchor: 'items', - $dynamicRef: '#items', - type: 'object' + type: 'array', + items: { $dynamicRef: '#node' }, + $defs: { + node: { $dynamicAnchor: 'node', type: 'string' } + } }; const ls = getLanguageService({}); - const { textDoc, jsonDoc } = toDocument(JSON.stringify({ name: 'test' })); + const { textDoc, jsonDoc } = toDocument(JSON.stringify(['a', 'b'])); const validation = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); - assert.strictEqual(validation.length, 1); - assert.ok(validation[0].message.includes('$dynamicRef')); - assert.ok(validation[0].message.includes('$dynamicAnchor')); - assert.ok(!validation[0].message.includes('$vocabulary')); // $vocabulary is now supported - assert.ok(validation[0].message.includes('not yet supported')); - assert.strictEqual(validation[0].severity, DiagnosticSeverity.Warning); - assert.strictEqual(validation[0].code, ErrorCode.SchemaUnsupportedFeature); + assert.strictEqual(validation.length, 0); }); suite('Vocabulary Support (JSON Schema 2019-09+)', () => { From 412958a33b1d280627a9f2e2b3a6b2dc2aee59d3 Mon Sep 17 00:00:00 2001 From: Keegan Caruso Date: Mon, 6 Jul 2026 14:30:02 -0700 Subject: [PATCH 2/6] feat: add dynamic-scope resolution for $dynamicRef (2020-12) 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. --- src/jsonSchema.ts | 66 +++++++++-- src/parser/jsonParser.ts | 42 +++++-- src/services/jsonSchemaService.ts | 168 +++++++++++++++++++++------ src/test/jsonSchemaTestSuite.test.ts | 10 -- src/test/schema.test.ts | 53 +++++++++ 5 files changed, 277 insertions(+), 62 deletions(-) diff --git a/src/jsonSchema.ts b/src/jsonSchema.ts index 6b717688..ef5e103d 100644 --- a/src/jsonSchema.ts +++ b/src/jsonSchema.ts @@ -107,12 +107,64 @@ export interface MergedJSONSchema extends JSONSchema { $originalId?: string; /** - * Non-enumerable metadata attached while resolving a `$dynamicRef`. - * - * `$dynamicRefTarget` is the statically resolved initial target (resolved - * like a plain `$ref`, against the reference's own base URI). The validator - * validates the instance against it, so a `$dynamicRef` behaves like a plain - * `$ref` to the location it initially points at. + * 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. */ - $dynamicRefTarget?: JSONSchema; + $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; + + /** + * 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; } diff --git a/src/parser/jsonParser.ts b/src/parser/jsonParser.ts index 81dfa516..40cc40da 100644 --- a/src/parser/jsonParser.ts +++ b/src/parser/jsonParser.ts @@ -471,19 +471,37 @@ function validate(n: ASTNode | undefined, schema: JSONSchema, validationResult: // Push current schema to stack for $recursiveRef resolution schemaStack.push(schema); - // 2020-12 $dynamicRef, resolved here like a plain $ref. The target was - // recorded during schema resolution as $dynamicRefTarget; validate the - // instance against it. (Dynamic-scope resolution is layered on in a later - // step.) This runs after the schemaRoots push so the resource that contains - // the $dynamicRef participates in its own resolution. - const dynamicRefTarget = (schema as MergedJSONSchema).$dynamicRefTarget; - if (dynamicRefTarget !== undefined && dynamicRefTarget !== schema) { - validate(node, dynamicRefTarget, validationResult, matchingSchemas, context, schemaStack, schemaRoots); - schemaStack.pop(); - if (isNewRoot) { - schemaRoots.pop(); + // 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); } - return; } const enabled = (keyword: string) => { diff --git a/src/services/jsonSchemaService.ts b/src/services/jsonSchemaService.ts index f3bea259..e8c4543f 100644 --- a/src/services/jsonSchemaService.ts +++ b/src/services/jsonSchemaService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as Json from 'jsonc-parser'; -import { JSONSchema, JSONSchemaRef, MergedJSONSchema } from '../jsonSchema.js'; +import { JSONSchema, JSONSchemaRef, MergedJSONSchema, DynamicRefInfo, AnchorMaps } from '../jsonSchema.js'; import { URI } from 'vscode-uri'; import * as Strings from '../utils/strings.js'; import { asSchema, getSchemaDraftFromId, JSONDocument, normalizeId } from '../parser/jsonParser.js'; @@ -535,6 +535,18 @@ export class JSONSchemaService implements IJSONSchemaService { }); }; + // Get (creating on first use) the hidden $dynamicRefInfo record for a $dynamicRef + // node. Its fields are populated across two passes — `scope` while collecting + // anchors, `target`/`name` while resolving the reference — over the same object. + const dynamicRefInfoOf = (node: MergedJSONSchema): DynamicRefInfo => { + let info = node.$dynamicRefInfo; + if (!info) { + info = {}; + setHidden(node, '$dynamicRefInfo', info); + } + return info; + }; + const findSectionByJSONPointer = (schema: JSONSchema, path: string): any => { path = decodeURIComponent(path); let current: any = schema; @@ -620,13 +632,14 @@ export class JSONSchemaService implements IJSONSchemaService { // Propagate hidden $dynamicRef metadata. When a $dynamicRef lives directly on // a $ref'd schema resource, that resource is a distinct object from the // referencing schema and is often resolved standalone first — deleting its - // $dynamicRef and recording the (non-enumerable, so not copied above) target. - // Carry it over so the merged schema still resolves the reference. (When the - // $dynamicRef lives on a child of the merged resource, that child is shared by - // reference and already carries its own metadata.) + // $dynamicRef and recording the (non-enumerable, so not copied above) + // $dynamicRefInfo. Carry it over so the merged schema still resolves + // dynamically. (When the $dynamicRef lives on a child of the merged resource, + // that child is shared by reference and already carries its own metadata.) const src = section as MergedJSONSchema; - if (src.$dynamicRefTarget !== undefined && (target as MergedJSONSchema).$dynamicRefTarget === undefined) { - setHidden(target, '$dynamicRefTarget', src.$dynamicRefTarget); + const dst = target as MergedJSONSchema; + if (src.$dynamicRefInfo !== undefined && dst.$dynamicRefInfo === undefined) { + setHidden(target, '$dynamicRefInfo', src.$dynamicRefInfo); } }; @@ -798,6 +811,55 @@ export class JSONSchemaService implements IJSONSchemaService { }); }; + const resolveDynamicRef = (schema: JSONSchema, newBase: JSONSchema, newBaseHandle: SchemaHandle, currentBaseHandle: SchemaHandle): PromiseLike | undefined => { + // 2020-12 $dynamicRef. Its *initial* target is resolved statically, exactly + // like a plain $ref, and kept as hidden metadata (info.target) instead of + // being merged into `schema`, so `schema`'s own keywords are preserved. The + // referenced plain-name fragment is recorded as info.name. The "bookending" + // decision (does the initial target name a $dynamicAnchor?) and the + // dynamic-scope walk are both deferred to validation time, where the (possibly + // asynchronously resolved) target is fully populated. Returns the external- + // resolution promise (if any) for the caller to add to openPromises. + const info = dynamicRefInfoOf(schema as MergedJSONSchema); + const ref = schema.$dynamicRef!; + const segments = ref.split('#', 2); + delete schema.$dynamicRef; + + // A JSON-pointer fragment ("#/…") or a missing fragment can never name a + // $dynamicAnchor, so such a $dynamicRef always behaves like a plain $ref. + const fragment = segments[1]; + const dynamicName = (isString(fragment) && fragment.length > 0 && fragment.charAt(0) !== '/') ? fragment : undefined; + if (dynamicName !== undefined) { + info.name = dynamicName; + } + + if (segments[0].length > 0) { + // External / relative reference: resolve the target document into a + // throwaway container so `schema`'s own keywords are preserved. + const target: JSONSchema = {}; + info.target = target; + const refBase = (newBase === schema) ? currentBaseHandle : newBaseHandle; + return resolveExternalLink(target, segments[0], segments[1], refBase); + } + + // Internal reference. Resolve #name within the *lexical* schema resource + // (recorded as info.scope before $ref merging flattened resource boundaries) + // so a sibling resource's identically-named anchor cannot leak in. + let target: JSONSchema | undefined; + if (dynamicName !== undefined && info.scope?.$anchorMaps) { + target = info.scope.$anchorMaps.local.get(dynamicName); + } + if (target === undefined) { + // JSON-pointer fragment, or no lexical anchor found: fall back to a plain + // static $ref resolution against the current base. + const container: JSONSchema = {}; + mergeRef(container, newBase, newBaseHandle, segments[1]); + target = container; + } + info.target = target; + return undefined; + }; + const resolveRefs = (node: JSONSchema, parentSchema: JSONSchema, parentHandle: SchemaHandle): PromiseLike => { const openPromises: PromiseLike[] = []; @@ -864,30 +926,12 @@ export class JSONSchemaService implements IJSONSchemaService { } } - if (schema.$dynamicRef) { - // 2020-12 $dynamicRef, resolved here as a plain $ref: its target is - // resolved statically (against the reference's own base URI) and kept - // as hidden metadata ($dynamicRefTarget) instead of being merged into - // `schema`, so `schema`'s own sibling keywords are preserved. The - // validator then validates the instance against that target. (True - // dynamic-scope resolution is layered on top of this in a later step.) - const ref = schema.$dynamicRef; - const segments = ref.split('#', 2); - delete schema.$dynamicRef; - - if (segments[0].length > 0) { - // External / relative reference: resolve the target document into a - // throwaway container so `schema`'s own keywords are preserved. - const target: JSONSchema = {}; - setHidden(schema, '$dynamicRefTarget', target); - const refBase = (newBase === schema) ? currentBaseHandle : newBaseHandle; - openPromises.push(resolveExternalLink(target, segments[0], segments[1], refBase)); - } else { - // Internal reference: resolve like a plain $ref against the current - // base into a throwaway container. - const container: JSONSchema = {}; - mergeRef(container, newBase, newBaseHandle, segments[1]); - setHidden(schema, '$dynamicRefTarget', container); + // $dynamicRef is a 2020-12 core keyword. In earlier drafts it is an + // unknown keyword, so leave it untouched and unresolved (ignored) there. + if (schema.$dynamicRef && (schemaDraft === undefined || schemaDraft >= SchemaDraft.v2020_12)) { + const dynamicRefPromise = resolveDynamicRef(schema, newBase, newBaseHandle, currentBaseHandle); + if (dynamicRefPromise) { + openPromises.push(dynamicRefPromise); } } @@ -953,6 +997,62 @@ export class JSONSchemaService implements IJSONSchemaService { return result; }; + // 2020-12: group $anchor / $dynamicAnchor declarations by the schema resource + // ($id scope) that contains them, and attach each resource's name→node maps to + // its resource-root node as hidden metadata ($anchorMaps): + // - $anchorMaps.dynamic: only $dynamicAnchor names, used by the validator to + // walk the dynamic scope (outermost resource first). + // - $anchorMaps.local: both $anchor and $dynamicAnchor names, used to resolve + // an internal $dynamicRef's initial target within its own resource. + // Each $dynamicRef node also gets its lexical resource recorded as + // $dynamicRefInfo.scope. This must run on the original schema, before $ref + // resolution merges (and thereby flattens) resource boundaries; node identities + // are preserved through in-place resolution, so the attached metadata stays valid. + const collectDynamicAnchors = (root: JSONSchema): void => { + if (!(schemaDraft === undefined || schemaDraft >= SchemaDraft.v2020_12)) { + return; + } + const seen = new Set(); + const visit = (node: JSONSchema, resourceRoot: MergedJSONSchema): void => { + if (!node || typeof node !== 'object' || seen.has(node)) { + return; + } + seen.add(node); + + // A node with its own $id (or the document root) starts a new resource. + let currentRoot = resourceRoot; + const id = getSchemaId(node); + if (node === root || (isString(id) && id.charAt(0) !== '#')) { + currentRoot = node as MergedJSONSchema; + if (!currentRoot.$anchorMaps) { + const maps: AnchorMaps = { dynamic: new Map(), local: new Map() }; + setHidden(currentRoot, '$anchorMaps', maps); + } + } + + const maps = currentRoot.$anchorMaps!; + if (isString(node.$dynamicAnchor)) { + if (!maps.dynamic.has(node.$dynamicAnchor)) { + maps.dynamic.set(node.$dynamicAnchor, node); + } + if (!maps.local.has(node.$dynamicAnchor)) { + maps.local.set(node.$dynamicAnchor, node); + } + } + if (isString(node.$anchor) && !maps.local.has(node.$anchor)) { + maps.local.set(node.$anchor, node); + } + if (isString(node.$dynamicRef)) { + dynamicRefInfoOf(node as MergedJSONSchema).scope = currentRoot; + } + + JSONSchemaService.traverseSchemaProperties(node, (childSchema) => { + visit(childSchema, currentRoot); + }); + }; + visit(root, root as MergedJSONSchema); + }; + // Collect and register embedded schemas with $id so they can be resolved as external refs // This traversal tracks the current base URI so nested $id values are resolved correctly const registerEmbeddedSchemas = (root: JSONSchema, baseUri: string): void => { @@ -1002,10 +1102,12 @@ export class JSONSchemaService implements IJSONSchemaService { // Collect anchors eagerly before $ref resolution mutates the schema. // resolveRefs merges referenced nodes (including $anchor) into $ref targets, // so a lazy collectAnchors call could see duplicates from merged copies. - // $dynamicAnchor names are registered as ordinary anchors here (see - // collectAnchors), so a $dynamicRef resolves to them like a plain $ref. handle.anchors = collectAnchors(schema); + // Collect $dynamicAnchor maps eagerly too, for the same reason: resource + // boundaries must be read before $ref resolution flattens them. + collectDynamicAnchors(schema); + // Resolve meta-schema to extract vocabularies if present const resolveMetaschemaVocabularies = (): PromiseLike => { if (!schema.$schema || typeof schema.$schema !== 'string') { diff --git a/src/test/jsonSchemaTestSuite.test.ts b/src/test/jsonSchemaTestSuite.test.ts index 39abfa6e..880d53ae 100644 --- a/src/test/jsonSchemaTestSuite.test.ts +++ b/src/test/jsonSchemaTestSuite.test.ts @@ -199,15 +199,5 @@ function initializeTests() { }); } const skippedTests = new Set([ - // These require true dynamic-scope resolution of $dynamicRef, which is added in a - // follow-up change. With the static ($ref-like) fallback they resolve to the wrong - // (lexically-initial) target, so they are skipped until dynamic resolution lands. - "draft2020-12/dynamicRef.json/An $anchor with the same name as a $dynamicAnchor should not be used for dynamic scope resolution/Any array is valid", - "draft2020-12/dynamicRef.json/A $dynamicRef without a matching $dynamicAnchor in the same schema resource should behave like a normal $ref to $anchor/Any array is valid", - "draft2020-12/dynamicRef.json/A $dynamicRef with a non-matching $dynamicAnchor in the same schema resource should behave like a normal $ref to $anchor/Any array is valid", - "draft2020-12/dynamicRef.json/A $dynamicRef that initially resolves to a schema with a matching $dynamicAnchor should resolve to the first $dynamicAnchor in the dynamic scope/The recursive part is not valid against the root", - "draft2020-12/dynamicRef.json/multiple dynamic paths to the $dynamicRef keyword/recurse to integerNode - floats are not allowed", - "draft2020-12/dynamicRef.json/after leaving a dynamic scope, it should not be used by a $dynamicRef/string matches /$defs/thingy, but the $dynamicRef does not stop here", - "draft2020-12/dynamicRef.json/after leaving a dynamic scope, it should not be used by a $dynamicRef//then/$defs/thingy is the final stop for the $dynamicRef", ]); initializeTests(); \ No newline at end of file diff --git a/src/test/schema.test.ts b/src/test/schema.test.ts index cb3d7755..94fa127a 100644 --- a/src/test/schema.test.ts +++ b/src/test/schema.test.ts @@ -2451,6 +2451,59 @@ suite('JSON Schema', () => { assert.strictEqual(validation.length, 0); }); + test('$dynamicRef applies alongside sibling keywords', async function () { + const schema: JSONSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'array', + // maxLength is a sibling of $dynamicRef on the same node: per 2020-12 it + // must still be applied, not skipped just because $dynamicRef is present. + items: { $dynamicRef: '#node', maxLength: 2 }, + $defs: { + node: { $dynamicAnchor: 'node', type: 'string' } + } + }; + + const ls = getLanguageService({}); + + // Valid: every item is a string of length <= 2 + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify(['ok', 'hi'])); + const validation = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); + assert.strictEqual(validation.length, 0); + } + // Invalid: 'toolong' resolves through $dynamicRef to the string schema, but + // violates the sibling maxLength keyword on the same node. + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify(['ok', 'toolong'])); + const validation = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); + assert.ok(validation.length > 0, 'sibling maxLength alongside $dynamicRef should be enforced'); + } + }); + + test('$dynamicRef is ignored as an unknown keyword before 2020-12', async function () { + // In draft-07 $dynamicRef is not a keyword; it must be ignored (left + // unresolved), not resolved like a reference (which previously produced a + // spurious "cannot be resolved" error). + const schema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + list: { $dynamicRef: '#node' } + }, + $defs: { + node: { $dynamicAnchor: 'node', type: 'array' } + } + }; + + const ls = getLanguageService({}); + + // $dynamicRef ignored -> `list` is unconstrained -> a non-array value is valid. + const { textDoc, jsonDoc } = toDocument(JSON.stringify({ list: 'not-an-array' })); + const validation = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); + + assert.strictEqual(validation.length, 0); + }); + suite('Vocabulary Support (JSON Schema 2019-09+)', () => { // Schema with no $schema - should use all keywords test('schema without $schema should validate all keywords', async function () { From e61dfcf9ab363fe9900a228f130bdff97edc854e Mon Sep 17 00:00:00 2001 From: Keegan Caruso Date: Tue, 7 Jul 2026 12:03:16 -0700 Subject: [PATCH 3/6] test: cover $dynamicRef/$dynamicAnchor edge cases (2020-12) 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 --- src/test/completion.test.ts | 26 +++++ src/test/hover.test.ts | 16 +++ src/test/schema.test.ts | 204 ++++++++++++++++++++++++++++++++++++ 3 files changed, 246 insertions(+) diff --git a/src/test/completion.test.ts b/src/test/completion.test.ts index ad72a373..06cf7075 100644 --- a/src/test/completion.test.ts +++ b/src/test/completion.test.ts @@ -255,6 +255,32 @@ suite('JSON Completion', () => { }); + test('Complete properties through $dynamicRef (2020-12)', async function () { + const schema: JSONSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + node: { $dynamicRef: '#node' } + }, + $defs: { + node: { + $dynamicAnchor: 'node', + type: 'object', + properties: { + foo: { type: 'string' }, + bar: { type: 'number' } + } + } + } + }; + await testCompletionsFor('{ "node": { | } }', schema, { + items: [ + { label: 'foo' }, + { label: 'bar' } + ] + }); + }); + test('Complete JS inherited property with schema', async function () { const schema: JSONSchema = { type: 'object', diff --git a/src/test/hover.test.ts b/src/test/hover.test.ts index 3e663fd1..ebe3ebde 100644 --- a/src/test/hover.test.ts +++ b/src/test/hover.test.ts @@ -107,6 +107,22 @@ suite('JSON Hover', () => { }); }); + test('Hover resolves through $dynamicRef (2020-12)', async function () { + const schema: JSONSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + node: { $dynamicRef: '#node' } + }, + $defs: { + node: { $dynamicAnchor: 'node', type: 'string', description: 'Resolved through dynamicRef' } + } + }; + // Hover over the value governed by the $dynamicRef. + const result = await testComputeInfo('{ "node": "value" }', schema, { line: 0, character: 12 }); + assert.deepEqual(result.contents, ['Resolved through dynamicRef']); + }); + test('Enum description', async function () { const schema: JSONSchema = { type: 'object', diff --git a/src/test/schema.test.ts b/src/test/schema.test.ts index 94fa127a..14965b13 100644 --- a/src/test/schema.test.ts +++ b/src/test/schema.test.ts @@ -2504,6 +2504,210 @@ suite('JSON Schema', () => { assert.strictEqual(validation.length, 0); }); + test('$dynamicAnchor on a sub-schema shared by reference is registered once', async function () { + // The same sub-schema object is reachable via two properties. The anchor + // collection passes must visit it only once (shared-node / cycle guard) and + // must not raise a spurious duplicate-anchor error. + const shared: JSONSchema = { $dynamicAnchor: 'shared', type: 'string' }; + const schema: JSONSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { a: shared, b: shared } + }; + + const ls = getLanguageService({}); + + // Valid: both properties resolve through the shared string schema. + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify({ a: 'x', b: 'y' })); + const validation = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); + assert.strictEqual(validation.length, 0, 'shared sub-schema should validate without errors: ' + JSON.stringify(validation)); + } + // Invalid: the shared schema still constrains each usage. + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify({ a: 1 })); + const validation = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); + assert.ok(validation.length > 0, 'shared sub-schema should still enforce its type'); + } + }); + + test('duplicate $dynamicAnchor is reported as a schema resolution error', async function () { + // In 2020-12 a $dynamicAnchor also declares a plain-name anchor, so two + // different nodes claiming the same name in one resource is a conflict. + const service = new SchemaService.JSONSchemaService(newMockRequestService(), workspaceContext); + service.setSchemaContributions({ + schemas: { + 'https://myschemastore/dup': { + $id: 'https://myschemastore/dup', + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + a: { $dynamicAnchor: 'dup', type: 'string' }, + b: { $dynamicAnchor: 'dup', type: 'number' } + } + } + } + }); + + const resolved = await service.getResolvedSchema('https://myschemastore/dup'); + assert.ok(resolved, 'expected a resolved schema'); + assert.ok( + resolved!.errors.some(e => e.message.includes('Duplicate anchor declaration')), + 'expected a duplicate-anchor error, got: ' + JSON.stringify(resolved!.errors) + ); + }); + + test('$dynamicRef resolves dynamically across the dynamic scope (override)', async function () { + // A generic "extendible" resource whose array items resolve through a + // $dynamicRef that is bookended to a permissive default $dynamicAnchor. + const base: JSONSchema = { + $id: 'https://example.com/base', + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + elements: { type: 'array', items: { $dynamicRef: '#elements' } } + }, + required: ['elements'], + $defs: { + elements: { $dynamicAnchor: 'elements' } + } + }; + const ls = getLanguageService({ schemaRequestService: newMockRequestService({ 'https://example.com/base': base }), workspaceContext }); + + // Extending schema: brings a stricter $dynamicAnchor 'elements' into the + // dynamic scope, which must override the base default during evaluation. + const strict: JSONSchema = { + $id: 'https://example.com/strict', + $schema: 'https://json-schema.org/draft/2020-12/schema', + $ref: 'https://example.com/base', + $defs: { + elements: { + $dynamicAnchor: 'elements', + type: 'object', + properties: { a: { type: 'integer' } }, + required: ['a'], + additionalProperties: false + } + } + }; + + // Override active: each element must match the strict def. + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify({ elements: [{ a: 1 }] })); + const v = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, strict); + assert.strictEqual(v.length, 0, 'strict elements should be valid: ' + JSON.stringify(v)); + } + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify({ elements: [{ b: 1 }] })); + const v = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, strict); + assert.ok(v.length > 0, 'override should reject an element missing "a"'); + } + // Contrast: the base alone applies the permissive default (no override). + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify({ elements: [{ b: 1 }] })); + const v = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, base); + assert.strictEqual(v.length, 0, 'base default should permit any element: ' + JSON.stringify(v)); + } + }); + + test('$dynamicRef to an $anchor (not a $dynamicAnchor) behaves like a plain $ref', async function () { + // #foo resolves to a plain $anchor, which is not "bookended" (its target is + // not a $dynamicAnchor of that name), so there is no dynamic-scope walk and + // it behaves like a normal $ref to the string schema. + const schema: JSONSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'array', + items: { $dynamicRef: '#foo' }, + $defs: { + foo: { $anchor: 'foo', type: 'string' } + } + }; + const ls = getLanguageService({}); + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify(['a', 'b'])); + const v = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); + assert.strictEqual(v.length, 0); + } + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify([1])); + const v = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); + assert.ok(v.length > 0, 'non-bookended $dynamicRef should still enforce the target schema'); + } + }); + + test('$dynamicRef with a JSON-pointer fragment behaves like a plain $ref', async function () { + // A JSON-pointer fragment can never name a $dynamicAnchor, so it always + // resolves statically like $ref. + const schema: JSONSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'array', + items: { $dynamicRef: '#/$defs/node' }, + $defs: { + node: { type: 'string' } + } + }; + const ls = getLanguageService({}); + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify(['a'])); + const v = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); + assert.strictEqual(v.length, 0); + } + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify([1])); + const v = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); + assert.ok(v.length > 0); + } + }); + + test('$dynamicRef resolves through an external schema', async function () { + // The reference has a URI part, exercising external resolution of the + // initial target across documents. + const ext: JSONSchema = { + $id: 'https://example.com/ext', + $schema: 'https://json-schema.org/draft/2020-12/schema', + $defs: { + node: { $dynamicAnchor: 'node', type: 'string' } + } + }; + const ls = getLanguageService({ schemaRequestService: newMockRequestService({ 'https://example.com/ext': ext }), workspaceContext }); + const schema: JSONSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'array', + items: { $dynamicRef: 'https://example.com/ext#node' } + }; + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify(['a'])); + const v = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); + assert.strictEqual(v.length, 0, 'external $dynamicRef should validate a string: ' + JSON.stringify(v)); + } + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify([1])); + const v = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); + assert.ok(v.length > 0, 'external $dynamicRef should reject a non-string'); + } + }); + + test('$dynamicRef is ignored as an unknown keyword in draft 2019-09', async function () { + // 2019-09 uses $recursiveRef, not $dynamicRef, so $dynamicRef must be ignored + // (left unresolved), not resolved like a reference. + const schema: JSONSchema = { + $schema: 'https://json-schema.org/draft/2019-09/schema', + type: 'object', + properties: { + list: { $dynamicRef: '#node' } + }, + $defs: { + node: { $dynamicAnchor: 'node', type: 'array' } + } + }; + const ls = getLanguageService({}); + + // $dynamicRef ignored -> `list` is unconstrained -> a non-array value is valid. + const { textDoc, jsonDoc } = toDocument(JSON.stringify({ list: 'not-an-array' })); + const v = await ls.doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, schema); + assert.strictEqual(v.length, 0); + }); + suite('Vocabulary Support (JSON Schema 2019-09+)', () => { // Schema with no $schema - should use all keywords test('schema without $schema should validate all keywords', async function () { From b39120a4912b9843c3ee799f17959282557903b3 Mon Sep 17 00:00:00 2001 From: Keegan Caruso Date: Wed, 8 Jul 2026 09:18:32 -0700 Subject: [PATCH 4/6] fix: extend $dynamicRef dynamic scope across external documents (2020-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> --- src/services/jsonSchemaService.ts | 39 +++++++++- src/test/schema.test.ts | 118 ++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/src/services/jsonSchemaService.ts b/src/services/jsonSchemaService.ts index e8c4543f..a89470be 100644 --- a/src/services/jsonSchemaService.ts +++ b/src/services/jsonSchemaService.ts @@ -641,6 +641,31 @@ export class JSONSchemaService implements IJSONSchemaService { if (src.$dynamicRefInfo !== undefined && dst.$dynamicRefInfo === undefined) { setHidden(target, '$dynamicRefInfo', src.$dynamicRefInfo); } + + // When the merged section is itself a schema resource (it carries anchor + // maps), fold its $dynamicAnchor / $anchor maps into the target's. The + // merged node stands in for that resource during validation (it becomes a + // dynamic-scope root via $originalId), so those anchors must participate in + // dynamic-scope resolution — this is what lets a $dynamicAnchor defined in + // an external (or $ref'd sub-) resource override a base default. Existing + // entries are kept so the outermost resource retains precedence. + if (src.$anchorMaps !== undefined) { + let maps = dst.$anchorMaps; + if (maps === undefined) { + maps = { dynamic: new Map(), local: new Map() }; + setHidden(target, '$anchorMaps', maps); + } + for (const [name, node] of src.$anchorMaps.dynamic) { + if (!maps.dynamic.has(name)) { + maps.dynamic.set(name, node); + } + } + for (const [name, node] of src.$anchorMaps.local) { + if (!maps.local.has(name)) { + maps.local.set(name, node); + } + } + } }; type SchemaKeyword = keyof JSONSchema; @@ -806,6 +831,14 @@ export class JSONSchemaService implements IJSONSchemaService { const errorMessage = refSegment ? l10n.t('Problems loading reference \'{0}\': {1}', refSegment, error.message) : error.message; resolveErrors.push(toDiagnostic(errorMessage, error.code, uri)); } + // 2020-12: collect the referenced document's own resource anchor maps + // (once) before merging, so its $dynamicAnchor declarations can join the + // dynamic scope and its $dynamicRef nodes get their lexical resource + // recorded — mirroring what is done eagerly for the root document, before + // $ref merging flattens resource boundaries. + if ((unresolvedSchema.schema as MergedJSONSchema).$anchorMaps === undefined) { + collectDynamicAnchors(unresolvedSchema.schema); + } mergeRef(node, unresolvedSchema.schema, referencedHandle, refSegment); return resolveRefs(node, unresolvedSchema.schema, referencedHandle); }); @@ -1009,7 +1042,11 @@ export class JSONSchemaService implements IJSONSchemaService { // resolution merges (and thereby flattens) resource boundaries; node identities // are preserved through in-place resolution, so the attached metadata stays valid. const collectDynamicAnchors = (root: JSONSchema): void => { - if (!(schemaDraft === undefined || schemaDraft >= SchemaDraft.v2020_12)) { + // Respect the resource's own $schema (like collectAnchors) so a referenced + // document with a different, pre-2020-12 dialect is not given $dynamicAnchor + // semantics just because the root document is 2020-12. + const draft = root && typeof root === 'object' && root.$schema ? getSchemaDraftFromId(root.$schema) : schemaDraft; + if (!(draft === undefined || draft >= SchemaDraft.v2020_12)) { return; } const seen = new Set(); diff --git a/src/test/schema.test.ts b/src/test/schema.test.ts index 14965b13..18ed5635 100644 --- a/src/test/schema.test.ts +++ b/src/test/schema.test.ts @@ -2610,6 +2610,124 @@ suite('JSON Schema', () => { } }); + test('$dynamicRef override resolves across external documents', async function () { + // Like the override test above, but the extending resource that carries the + // stricter $dynamicAnchor lives in a *separate* document from the one being + // validated. The dynamic scope must reach into the external "strict" document + // so its $dynamicAnchor overrides the base default. + const base: JSONSchema = { + $id: 'https://example.com/x-base', + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + elements: { type: 'array', items: { $dynamicRef: '#elements' } } + }, + required: ['elements'], + $defs: { + elements: { $dynamicAnchor: 'elements' } + } + }; + const strict: JSONSchema = { + $id: 'https://example.com/x-strict', + $schema: 'https://json-schema.org/draft/2020-12/schema', + $ref: 'https://example.com/x-base', + $defs: { + elements: { + $dynamicAnchor: 'elements', + type: 'object', + properties: { a: { type: 'integer' } }, + required: ['a'], + additionalProperties: false + } + } + }; + const makeLs = () => getLanguageService({ + schemaRequestService: newMockRequestService({ + 'https://example.com/x-base': base, + 'https://example.com/x-strict': strict + }), + workspaceContext + }); + + // The validated document is neither base nor strict: it only references strict. + // A fresh service *and* a fresh consumer per case: the cross-document dynamic + // scope must be established by resolution alone, not leaked from a previous + // validation (in-place $ref resolution mutates the schema object it is given). + const makeConsumer = (): JSONSchema => ({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + $ref: 'https://example.com/x-strict' + }); + + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify({ elements: [{ a: 1 }] })); + const v = await makeLs().doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, makeConsumer()); + assert.strictEqual(v.length, 0, 'strict element should be valid through external override: ' + JSON.stringify(v)); + } + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify({ elements: [{ b: 1 }] })); + const v = await makeLs().doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, makeConsumer()); + assert.ok(v.length > 0, 'external override should reject an element missing "a"'); + } + }); + + test('$dynamicRef with a non-fragment URI starting point participates in the external dynamic scope', async function () { + // The $dynamicRef uses a non-fragment URI ("y-defs#T"): its starting point is + // resolved in another document, and the $dynamicAnchor that overrides it lives + // in a third document. Exercises both the external starting-point path and + // cross-document dynamic-scope resolution. + const defs: JSONSchema = { + $id: 'https://example.com/y-defs', + $schema: 'https://json-schema.org/draft/2020-12/schema', + $defs: { + T: { $dynamicAnchor: 'T', type: 'number' } + } + }; + const base: JSONSchema = { + $id: 'https://example.com/y-base', + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + required: ['val'], + properties: { + val: { $dynamicRef: 'https://example.com/y-defs#T' } + } + }; + const strict: JSONSchema = { + $id: 'https://example.com/y-strict', + $schema: 'https://json-schema.org/draft/2020-12/schema', + $ref: 'https://example.com/y-base', + $defs: { + T: { $dynamicAnchor: 'T', type: 'integer' } + } + }; + const makeLs = () => getLanguageService({ + schemaRequestService: newMockRequestService({ + 'https://example.com/y-defs': defs, + 'https://example.com/y-base': base, + 'https://example.com/y-strict': strict + }), + workspaceContext + }); + // A fresh service *and* a fresh consumer per case: the external override must be + // established by resolution, not leaked from a previous validation (in-place + // $ref resolution mutates the schema object it is given). + const makeConsumer = (): JSONSchema => ({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + $ref: 'https://example.com/y-strict' + }); + + { + const { textDoc, jsonDoc } = toDocument(JSON.stringify({ val: 1 })); + const v = await makeLs().doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, makeConsumer()); + assert.strictEqual(v.length, 0, 'integer should satisfy the external override: ' + JSON.stringify(v)); + } + { + // 1.5 is a valid number (the base default) but not an integer (the override). + const { textDoc, jsonDoc } = toDocument(JSON.stringify({ val: 1.5 })); + const v = await makeLs().doValidation(textDoc, jsonDoc, { schemaValidation: 'warning' }, makeConsumer()); + assert.ok(v.length > 0, 'external override (integer) should reject a non-integer number'); + } + }); + test('$dynamicRef to an $anchor (not a $dynamicAnchor) behaves like a plain $ref', async function () { // #foo resolves to a plain $anchor, which is not "bookended" (its target is // not a $dynamicAnchor of that name), so there is no dynamic-scope walk and From c3d876321c2543e71e5267a19d83460f81dbb077 Mon Sep 17 00:00:00 2001 From: Keegan Caruso Date: Wed, 8 Jul 2026 11:28:55 -0700 Subject: [PATCH 5/6] fix: correct reference-scope handling for 2019-09/2020-12 test suite 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. --- package-lock.json | 12 +-- package.json | 2 +- src/parser/jsonParser.ts | 5 +- src/services/jsonSchemaService.ts | 134 +++++++++++++++++++++------ src/test/jsonSchemaTestSuite.test.ts | 3 +- 5 files changed, 115 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index e8d7e125..a15b8499 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@typescript-eslint/eslint-plugin": "^8.57.0", "@typescript-eslint/parser": "^8.57.0", "eslint": "^9.39.4", - "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": "^5.9.3" } @@ -1183,8 +1183,8 @@ }, "node_modules/json-schema-test-suite": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/json-schema-org/JSON-Schema-Test-Suite.git#69acf52990b004240839ae19b4bec8fb01d50876", - "integrity": "sha512-r4QkUgo97QaZjv1s9dReqXKozxefqCFttOle1/+okdn2Lyilw6kFy0Bat2XIoDj/7ckVPTU0E8RIUe/GBO1p1A==", + "resolved": "git+ssh://git@github.com/json-schema-org/JSON-Schema-Test-Suite.git#92acb61eb772a932c077d5ffa634ded719d2d738", + "integrity": "sha512-DEiBceK+Wpr9txUUiIjTfbK7rFvf/HGDJqcAISRtrP5GoAMBRfIdgSNEaNmZfW5gTzZnlA7r4Fz2qiHa+HWiyQ==", "dev": true, "license": "MIT" }, @@ -2398,10 +2398,10 @@ "dev": true }, "json-schema-test-suite": { - "version": "git+ssh://git@github.com/json-schema-org/JSON-Schema-Test-Suite.git#69acf52990b004240839ae19b4bec8fb01d50876", - "integrity": "sha512-r4QkUgo97QaZjv1s9dReqXKozxefqCFttOle1/+okdn2Lyilw6kFy0Bat2XIoDj/7ckVPTU0E8RIUe/GBO1p1A==", + "version": "git+ssh://git@github.com/json-schema-org/JSON-Schema-Test-Suite.git#92acb61eb772a932c077d5ffa634ded719d2d738", + "integrity": "sha512-DEiBceK+Wpr9txUUiIjTfbK7rFvf/HGDJqcAISRtrP5GoAMBRfIdgSNEaNmZfW5gTzZnlA7r4Fz2qiHa+HWiyQ==", "dev": true, - "from": "json-schema-test-suite@https://github.com/json-schema-org/JSON-Schema-Test-Suite.git#69acf52990b004240839ae19b4bec8fb01d50876" + "from": "json-schema-test-suite@https://github.com/json-schema-org/JSON-Schema-Test-Suite.git#92acb61eb772a932c077d5ffa634ded719d2d738" }, "json-schema-traverse": { "version": "0.4.1", diff --git a/package.json b/package.json index 5d59d84d..ee63a76f 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@typescript-eslint/eslint-plugin": "^8.57.0", "@typescript-eslint/parser": "^8.57.0", "eslint": "^9.39.4", - "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": "^5.9.3" }, diff --git a/src/parser/jsonParser.ts b/src/parser/jsonParser.ts index 40cc40da..b038313e 100644 --- a/src/parser/jsonParser.ts +++ b/src/parser/jsonParser.ts @@ -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; } } diff --git a/src/services/jsonSchemaService.ts b/src/services/jsonSchemaService.ts index a89470be..d8683c6a 100644 --- a/src/services/jsonSchemaService.ts +++ b/src/services/jsonSchemaService.ts @@ -279,10 +279,16 @@ export class JSONSchemaService implements IJSONSchemaService { private promiseConstructor: PromiseConstructor; private static traverseSchemaProperties(node: JSONSchema, callback: (schema: JSONSchema) => void): void { - const singleSchemaProps = ['additionalItems', 'additionalProperties', 'not', 'contains', - 'propertyNames', 'if', 'then', 'else', 'unevaluatedItems', 'unevaluatedProperties', 'items'] as const; + // `$defs`/`definitions` are visited first so that a reusable resource they + // contain (which may itself carry a `$ref` chain) begins resolving before an + // `if`/`then`/`else`/`items`/… sibling references it. During asynchronous + // `$ref` resolution the referenced resource must be fully assembled before a + // referrer merges it, otherwise the referrer captures a half-resolved snapshot + // (e.g. a `$dynamicRef` chain reached through `then: { $ref: "numberList" }`). const schemaMapProps = ['definitions', '$defs', 'properties', 'patternProperties', 'dependencies', 'dependentSchemas'] as const; + const singleSchemaProps = ['additionalItems', 'additionalProperties', 'not', 'contains', + 'propertyNames', 'if', 'then', 'else', 'unevaluatedItems', 'unevaluatedProperties', 'items'] as const; const schemaArrayProps = ['anyOf', 'allOf', 'oneOf', 'prefixItems'] as const; const visitValue = (value: JSONSchemaRef | JSONSchemaRef[]): void => { @@ -295,13 +301,6 @@ export class JSONSchemaService implements IJSONSchemaService { } }; - for (const prop of singleSchemaProps) { - const propValue = node[prop]; - if (propValue) { - visitValue(propValue); - } - } - for (const prop of schemaMapProps) { const map = node[prop]; if (map && typeof map === 'object') { @@ -309,6 +308,13 @@ export class JSONSchemaService implements IJSONSchemaService { } } + for (const prop of singleSchemaProps) { + const propValue = node[prop]; + if (propValue) { + visitValue(propValue); + } + } + for (const prop of schemaArrayProps) { const propValue = node[prop]; if (propValue) { @@ -500,6 +506,10 @@ export class JSONSchemaService implements IJSONSchemaService { const resolveErrors: SchemaDiagnostic[] = schemaToResolve.errors.slice(0); const schema = schemaToResolve.schema; + // External documents whose embedded $id resources have already been registered + // as resolvable handles, so we only do it once per referenced document. + const embeddedRegisteredFor = new Set(); + const schemaDraft = schema.$schema ? getSchemaDraftFromId(schema.$schema) : undefined; if (schemaDraft === SchemaDraft.v3) { return this.promise.resolve(new ResolvedSchema({}, [toDiagnostic(l10n.t("Draft-03 schemas are not supported."), ErrorCode.SchemaUnsupportedFeature)], [], schemaDraft, undefined)); @@ -556,7 +566,9 @@ export class JSONSchemaService implements IJSONSchemaService { path.split('/').some((part) => { part = part.replace(/~1/g, '/').replace(/~0/g, '~'); current = current[part]; - return !current; + // A boolean `false` is a valid schema, not a "missing" section, so only + // stop on genuinely absent values (undefined/null). + return current === undefined || current === null; }); return current; }; @@ -575,7 +587,9 @@ export class JSONSchemaService implements IJSONSchemaService { path.split('/').some((part) => { part = part.replace(/~1/g, '/').replace(/~0/g, '~'); current = current[part]; - if (!current) { + // A boolean `false` is a valid schema, not a "missing" section, so only + // stop on genuinely absent values (undefined/null). + if (current === undefined || current === null) { return true; } const id = getSchemaId(current); @@ -601,6 +615,30 @@ export class JSONSchemaService implements IJSONSchemaService { const getSchemaId = (schema: JSONSchema): string | undefined => schema.$id || schema.id; + // Fold a source resource's $dynamicAnchor names into a target node's dynamic + // map, creating it on demand. Existing entries win, so the outermost resource + // in a dynamic scope retains precedence — this is what lets a $dynamicAnchor in + // an outer resource override an inner (referenced) one during the validation + // walk. Only the `dynamic` map is folded: the `local` map must stay per-resource + // (it resolves an internal $dynamicRef's initial target within its own lexical + // resource), so a sibling/referenced resource's identically-named anchor must + // not leak into it. + const unionAnchorMaps = (target: MergedJSONSchema, srcMaps: AnchorMaps | undefined): void => { + if (srcMaps === undefined) { + return; + } + let maps = target.$anchorMaps; + if (maps === undefined) { + maps = { dynamic: new Map(), local: new Map() }; + setHidden(target, '$anchorMaps', maps); + } + for (const [name, node] of srcMaps.dynamic) { + if (!maps.dynamic.has(name)) { + maps.dynamic.set(name, node); + } + } + }; + const merge = (target: MergedJSONSchema, section: any): void => { for (const key in section) { if (!section.hasOwnProperty(key) || key === 'id' || key === '$id') { @@ -649,23 +687,7 @@ export class JSONSchemaService implements IJSONSchemaService { // dynamic-scope resolution — this is what lets a $dynamicAnchor defined in // an external (or $ref'd sub-) resource override a base default. Existing // entries are kept so the outermost resource retains precedence. - if (src.$anchorMaps !== undefined) { - let maps = dst.$anchorMaps; - if (maps === undefined) { - maps = { dynamic: new Map(), local: new Map() }; - setHidden(target, '$anchorMaps', maps); - } - for (const [name, node] of src.$anchorMaps.dynamic) { - if (!maps.dynamic.has(name)) { - maps.dynamic.set(name, node); - } - } - for (const [name, node] of src.$anchorMaps.local) { - if (!maps.local.has(name)) { - maps.local.set(name, node); - } - } - } + unionAnchorMaps(dst, src.$anchorMaps); }; type SchemaKeyword = keyof JSONSchema; @@ -699,6 +721,7 @@ export class JSONSchemaService implements IJSONSchemaService { ]; const containsBoundKeywords: readonly SchemaKeyword[] = ['minContains', 'maxContains']; const conditionalBranchKeywords: readonly SchemaKeyword[] = ['then', 'else']; + const arrayApplicatorKeywords: readonly SchemaKeyword[] = ['items', 'prefixItems', 'additionalItems']; const uses2020_12ArrayAnnotations = schemaDraft === undefined || schemaDraft >= SchemaDraft.v2020_12; // Some keywords only make sense relative to adjacent keywords in the same schema object. @@ -725,10 +748,29 @@ export class JSONSchemaService implements IJSONSchemaService { return true; } + // Reverse of the above: the *referencing* schema declares unevaluatedItems + // while the referenced schema constrains items positionally and may contain + // internal self-references (`$ref: "#"`). Flattening would pull the referenced + // items into the referencing scope, so those self-references would inherit the + // referencing unevaluatedItems. Isolate so the referenced resource keeps its + // own annotation scope. + if (hasKeyword(referencingSchema, 'unevaluatedItems') && hasAnyKeyword(referencedSchema, arrayApplicatorKeywords)) { + return true; + } + if (hasKeyword(referencedSchema, 'contains') && hasAnyKeyword(referencingSchema, containsBoundKeywords)) { return true; } + // Both the referenced and referencing schemas constrain array items + // (tuple `items`, or 2020-12 `items`/`prefixItems`). A flatten-merge can + // only keep one `items`, silently dropping the other; isolate into an + // allOf so both position constraints apply and item-evaluation + // annotations (for unevaluatedItems) accumulate across both. + if (Array.isArray(referencedSchema.items) && Array.isArray(referencingSchema.items)) { + return true; + } + if (hasAnyKeyword(referencedSchema, containsBoundKeywords) && hasKeyword(referencingSchema, 'contains')) { return true; } @@ -759,6 +801,12 @@ export class JSONSchemaService implements IJSONSchemaService { // A $ref to a sub-schema with an $id (i.e #hello) section = findSchemaById(sourceRoot, sourceHandle, refSegment); } + // A boolean is a valid JSON Schema: `true` accepts everything ({}) and + // `false` rejects everything ({ not: {} }). Normalize so it merges like any + // other schema (and isn't mistaken for an unresolved section below). + if (typeof section === 'boolean') { + section = section ? {} : { not: {} }; + } if (section) { // If the found section contains a $ref that needs to be resolved // relative to a different base (e.g. it's inside a schema with $id), @@ -776,6 +824,16 @@ export class JSONSchemaService implements IJSONSchemaService { } } const reservedKeys = new Set(['$ref', '$defs', 'definitions', '$schema', '$id', 'id']); + // Keywords that must stay on the wrapper rather than move into the allOf + // sibling. Scope-anchor keywords ($recursiveAnchor/$dynamicAnchor) keep this + // resource discoverable as a $recursiveRef/$dynamicRef bookending target. + // The unevaluated* annotations must observe the results of *all* in-place + // applicators (including the $ref'd schema in allOf[0]); leaving them on the + // wrapper lets them see the aggregated annotations rather than only the + // sibling's own evaluations. + const keepOnWrapperKeys = new Set([ + '$recursiveAnchor', '$dynamicAnchor', 'unevaluatedItems', 'unevaluatedProperties' + ]); // In JSON Schema draft-04 through draft-07, $ref completely overrides any sibling keywords. // Starting in 2019-09, sibling keywords are processed alongside $ref. @@ -797,9 +855,10 @@ export class JSONSchemaService implements IJSONSchemaService { const siblingSchema: JSONSchema = {}; const refSchema = { ...section }; - // Move all existing properties from target to siblingSchema + // Move all existing properties from target to siblingSchema, except + // keywords that must remain on the wrapper resource. for (const key in target) { - if (target.hasOwnProperty(key) && !reservedKeys.has(key)) { + if (target.hasOwnProperty(key) && !reservedKeys.has(key) && !keepOnWrapperKeys.has(key)) { const k = key as keyof JSONSchema; siblingSchema[k] = target[k] as any; delete target[k]; @@ -831,6 +890,15 @@ export class JSONSchemaService implements IJSONSchemaService { const errorMessage = refSegment ? l10n.t('Problems loading reference \'{0}\': {1}', refSegment, error.message) : error.message; resolveErrors.push(toDiagnostic(errorMessage, error.code, uri)); } + // A referenced document may itself embed subschemas with their own + // absolute $id. Register those as resolvable handles (once per document) + // so a nested $ref by that $id resolves to the embedded resource instead + // of being (mis)loaded as a standalone document. registerEmbeddedSchemas + // only runs for the root document otherwise. + if (!embeddedRegisteredFor.has(uri)) { + embeddedRegisteredFor.add(uri); + registerEmbeddedSchemas(unresolvedSchema.schema, uri); + } // 2020-12: collect the referenced document's own resource anchor maps // (once) before merging, so its $dynamicAnchor declarations can join the // dynamic scope and its $dynamicRef nodes get their lexical resource @@ -840,6 +908,12 @@ export class JSONSchemaService implements IJSONSchemaService { collectDynamicAnchors(unresolvedSchema.schema); } mergeRef(node, unresolvedSchema.schema, referencedHandle, refSegment); + // Referencing a fragment of another resource (e.g. "other#/$defs/x") + // still *enters* that resource's dynamic scope, so its $dynamicAnchor + // declarations must join `node`'s scope even though only the sub-schema + // was merged. (For a whole-document ref the merge above already did this, + // since the merged section is the resource root; this is a no-op then.) + unionAnchorMaps(node as MergedJSONSchema, (unresolvedSchema.schema as MergedJSONSchema).$anchorMaps); return resolveRefs(node, unresolvedSchema.schema, referencedHandle); }); }; diff --git a/src/test/jsonSchemaTestSuite.test.ts b/src/test/jsonSchemaTestSuite.test.ts index 880d53ae..fbb83932 100644 --- a/src/test/jsonSchemaTestSuite.test.ts +++ b/src/test/jsonSchemaTestSuite.test.ts @@ -198,6 +198,5 @@ function initializeTests() { }); } -const skippedTests = new Set([ -]); +const skippedTests = new Set([]); initializeTests(); \ No newline at end of file From 1c600ba6335183eede9bf2425f1423423d729830 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Sat, 11 Jul 2026 11:30:04 +0200 Subject: [PATCH 6/6] update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63eab4dc..488e1810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`.