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`. diff --git a/package-lock.json b/package-lock.json index 8ce5c4ed..6c044bb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,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" } @@ -941,8 +941,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" }, @@ -1937,10 +1937,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 a29ad25e..af28a0be 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/jsonSchema.ts b/src/jsonSchema.ts index 149dd795..ef5e103d 100644 --- a/src/jsonSchema.ts +++ b/src/jsonSchema.ts @@ -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; + + /** + * 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 2b47f2b0..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; } } @@ -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 diff --git a/src/services/jsonSchemaService.ts b/src/services/jsonSchemaService.ts index 53e5b561..d8683c6a 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'; @@ -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,12 +506,15 @@ 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)); } - let usesUnsupportedFeatures = new Set(); let activeVocabularies: Vocabularies | undefined = undefined; const extractVocabularies = (metaschema: JSONSchema): Vocabularies | undefined => { @@ -524,6 +533,30 @@ 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 + }); + }; + + // 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; @@ -533,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; }; @@ -552,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); @@ -578,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') { @@ -605,6 +666,28 @@ 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) + // $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; + const dst = target as MergedJSONSchema; + 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. + unionAnchorMaps(dst, src.$anchorMaps); }; type SchemaKeyword = keyof JSONSchema; @@ -638,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. @@ -664,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; } @@ -698,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), @@ -715,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. @@ -736,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]; @@ -770,11 +890,83 @@ 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 + // 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); + // 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); }); }; + 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[] = []; @@ -841,11 +1033,13 @@ export class JSONSchemaService implements IJSONSchemaService { } } - if (schema.$dynamicRef) { - usesUnsupportedFeatures.add('$dynamicRef'); - } - if (schema.$dynamicAnchor) { - usesUnsupportedFeatures.add('$dynamicAnchor'); + // $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); + } } // Continue traversing child schemas with the potentially updated base @@ -881,16 +1075,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) => { @@ -903,6 +1104,66 @@ 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 => { + // 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(); + 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 => { @@ -954,6 +1215,10 @@ export class JSONSchemaService implements IJSONSchemaService { // so a lazy collectAnchors call could see duplicates from merged copies. 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') { @@ -986,11 +1251,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/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/jsonSchemaTestSuite.test.ts b/src/test/jsonSchemaTestSuite.test.ts index 9947181f..fbb83932 100644 --- a/src/test/jsonSchemaTestSuite.test.ts +++ b/src/test/jsonSchemaTestSuite.test.ts @@ -198,22 +198,5 @@ 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", - "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/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", -]); +const skippedTests = new Set([]); initializeTests(); \ No newline at end of file diff --git a/src/test/schema.test.ts b/src/test/schema.test.ts index 8e9b56e1..6d016811 100644 --- a/src/test/schema.test.ts +++ b/src/test/schema.test.ts @@ -2411,7 +2411,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', @@ -2423,55 +2423,429 @@ 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); - assertInMessage(validation[0].message, '$dynamicAnchor'); - assertInMessage(validation[0].message, '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); - assertInMessage(validation[0].message, '$dynamicRef'); - assertInMessage(validation[0].message, '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); - assertInMessage(validation[0].message, '$dynamicRef'); - assertInMessage(validation[0].message, '$dynamicAnchor'); - assertNotInMessage(validation[0].message, '$vocabulary'); // $vocabulary is now supported - assertInMessage(validation[0].message, 'not yet supported'); - assert.strictEqual(validation[0].severity, DiagnosticSeverity.Warning); - assert.strictEqual(validation[0].code, ErrorCode.SchemaUnsupportedFeature); + 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); + }); + + 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 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 + // 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+)', () => {