Skip to content

Commit 72d5ab6

Browse files
author
Keegan Caruso
committed
fix: correct vocabulary and draft-based keyword gating for JSON Schema 2019-09+
- Gate dependentRequired, dependentSchemas, unevaluatedProperties, unevaluatedItems, minContains, maxContains to 2019-09+ - Gate prefixItems to 2020-12+ - Gate dependencies to draft-07 and earlier - Apply vocabulary filtering only for 2019-09+ schemas - Make format annotation-only by default for 2019-09+ per spec - Support enabling format assertion via $vocabulary - Stop treating $id fragments as anchors in 2019-09+ - Fix relative $id resolution when reached via JSON pointer $ref - Highlight full object range for required property errors
1 parent eb89383 commit 72d5ab6

File tree

6 files changed

+551
-69
lines changed

6 files changed

+551
-69
lines changed

src/parser/jsonParser.ts

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -216,11 +216,12 @@ export interface ISchemaCollector {
216216

217217
export interface IEvaluationContext {
218218
readonly schemaDraft: SchemaDraft;
219+
readonly explicitSchemaDraft?: SchemaDraft;
219220
readonly activeVocabularies?: Vocabularies;
220221
}
221222

222223
class EvaluationContext implements IEvaluationContext {
223-
constructor(public readonly schemaDraft: SchemaDraft, public readonly activeVocabularies?: Vocabularies) {
224+
constructor(public readonly schemaDraft: SchemaDraft, public readonly activeVocabularies?: Vocabularies, public readonly explicitSchemaDraft?: SchemaDraft) {
224225
}
225226
}
226227

@@ -382,7 +383,8 @@ export class JSONDocument {
382383
public validate(textDocument: TextDocument, schema: JSONSchema | undefined, severity: DiagnosticSeverity = DiagnosticSeverity.Warning, schemaDraft?: SchemaDraft, activeVocabularies?: Vocabularies): Diagnostic[] | undefined {
383384
if (this.root && schema) {
384385
const validationResult = new ValidationResult();
385-
const context = new EvaluationContext(schemaDraft ?? getSchemaDraft(schema), activeVocabularies);
386+
const { explicitDraft, effectiveDraft } = resolveDrafts(schema, schemaDraft);
387+
const context = new EvaluationContext(effectiveDraft, activeVocabularies, explicitDraft);
386388
const schemaStack: JSONSchema[] = [];
387389
const schemaRoots: JSONSchema[] = [schema];
388390
validate(this.root, schema, validationResult, NoOpSchemaCollector.instance, context, schemaStack, schemaRoots);
@@ -397,8 +399,8 @@ export class JSONDocument {
397399
public getMatchingSchemas(schema: JSONSchema, focusOffset: number = -1, exclude?: ASTNode, activeVocabularies?: Vocabularies): IApplicableSchema[] {
398400
if (this.root && schema) {
399401
const matchingSchemas = new SchemaCollector(focusOffset, exclude);
400-
const schemaDraft = getSchemaDraft(schema);
401-
const context = new EvaluationContext(schemaDraft, activeVocabularies);
402+
const { explicitDraft, effectiveDraft } = resolveDrafts(schema);
403+
const context = new EvaluationContext(effectiveDraft, activeVocabularies, explicitDraft);
402404
const schemaStack: JSONSchema[] = [];
403405
const schemaRoots: JSONSchema[] = [schema];
404406
validate(this.root, schema, new ValidationResult(), matchingSchemas, context, schemaStack, schemaRoots);
@@ -407,14 +409,29 @@ export class JSONDocument {
407409
return [];
408410
}
409411
}
410-
function getSchemaDraft(schema: JSONSchema, fallBack = SchemaDraft.v2020_12) {
411-
let schemaId = schema.$schema;
412-
if (schemaId) {
413-
return getSchemaDraftFromId(schemaId) ?? fallBack;
414-
}
415-
return fallBack;
412+
413+
/*
414+
* Resolves the schema draft versions for validation.
415+
* - explicitDraft: the draft explicitly declared via $schema or passed as an override.
416+
* Undefined when no $schema is present and no override is provided.
417+
* - effectiveDraft: the draft used for keyword gating, defaulting to 2020-12.
418+
* These are tracked separately so that behaviors like format assertion can
419+
* distinguish "explicitly 2020-12" (annotation-only per spec) from
420+
* "no $schema, defaulting to 2020-12" (asserts for backward compatibility).
421+
*/
422+
function resolveDrafts(schema: JSONSchema, schemaDraftOverride?: SchemaDraft): { explicitDraft: SchemaDraft | undefined; effectiveDraft: SchemaDraft } {
423+
const explicitDraft = schemaDraftOverride ?? (schema.$schema ? getSchemaDraftFromId(schema.$schema) : undefined);
424+
const effectiveDraft = explicitDraft ?? SchemaDraft.v2020_12;
425+
return { explicitDraft, effectiveDraft };
416426
}
417427

428+
// Keywords introduced in 2019-09 (not available in draft-07 and earlier)
429+
const keywords201909 = new Set([
430+
'dependentRequired', 'dependentSchemas',
431+
'unevaluatedProperties', 'unevaluatedItems',
432+
'minContains', 'maxContains'
433+
]);
434+
418435
function validate(n: ASTNode | undefined, schema: JSONSchema, validationResult: ValidationResult, matchingSchemas: ISchemaCollector, context: IEvaluationContext, schemaStack: JSONSchema[], schemaRoots: JSONSchema[]): void {
419436

420437
if (!n || !matchingSchemas.include(n)) {
@@ -455,14 +472,29 @@ function validate(n: ASTNode | undefined, schema: JSONSchema, validationResult:
455472
schemaStack.push(schema);
456473

457474
const enabled = (keyword: string) => {
458-
// Special case for 'dependencies' which is a core keyword in draft-04 through draft-07 but moved to
459-
// dependentSchemas and dependentRequired in later versions.
460-
if (keyword === 'dependencies')
461-
{
475+
// Draft-based keyword gating: keywords only exist in certain drafts.
476+
// 'dependencies' was replaced by dependentRequired/dependentSchemas in 2019-09
477+
if (keyword === 'dependencies') {
462478
return context.schemaDraft <= SchemaDraft.v7;
463479
}
464-
465-
return isKeywordEnabled(keyword, context.activeVocabularies);
480+
481+
// Keywords introduced in 2019-09 (not available in draft-07 and earlier)
482+
if (keywords201909.has(keyword)) {
483+
return context.schemaDraft >= SchemaDraft.v2019_09;
484+
}
485+
486+
// 'prefixItems' was introduced in 2020-12
487+
if (keyword === 'prefixItems') {
488+
return context.schemaDraft >= SchemaDraft.v2020_12;
489+
}
490+
491+
// Vocabulary-based filtering only applies for 2019-09+ (vocabulary is not a concept in older drafts)
492+
if (context.schemaDraft >= SchemaDraft.v2019_09 && context.activeVocabularies) {
493+
return isKeywordEnabled(keyword, context.activeVocabularies);
494+
}
495+
496+
// No vocabulary info or pre-2019-09: enable all draft-appropriate keywords
497+
return true;
466498
};
467499

468500
_validateNode();
@@ -859,7 +891,7 @@ function validate(n: ASTNode | undefined, schema: JSONSchema, validationResult:
859891
}
860892

861893
// Only validate format if format-assertion vocabulary is active (not annotation-only)
862-
if (schema.format && enabled('format') && isFormatAssertionEnabled(context.activeVocabularies)) {
894+
if (schema.format && enabled('format') && isFormatAssertionEnabled(context.activeVocabularies, context.explicitSchemaDraft)) {
863895
switch (schema.format) {
864896
case 'uri':
865897
case 'uri-reference': {
@@ -1055,7 +1087,7 @@ function validate(n: ASTNode | undefined, schema: JSONSchema, validationResult:
10551087
for (const propertyName of schema.required) {
10561088
if (!seenKeys[propertyName]) {
10571089
validationResult.problems.push({
1058-
location: { offset: node.offset, length: 1 },
1090+
location: { offset: node.offset, length: node.length },
10591091
message: l10n.t('Missing property "{0}".', propertyName)
10601092
});
10611093
}

src/services/jsonSchemaService.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,37 @@ export class JSONSchemaService implements IJSONSchemaService {
535535
return current;
536536
};
537537

538+
// Like findSectionByJSONPointer, but also tracks the effective base URI
539+
// through any $id encountered along the path. This is needed so that
540+
// $ref values merged from the found section can be resolved against the
541+
// correct base, not the document root.
542+
const findSectionAndBase = (schema: JSONSchema, path: string, baseHandle: SchemaHandle): { section: any; baseHandle: SchemaHandle } => {
543+
path = decodeURIComponent(path);
544+
let current: any = schema;
545+
let currentBaseHandle = baseHandle;
546+
if (path[0] === '/') {
547+
path = path.substring(1);
548+
}
549+
path.split('/').some((part) => {
550+
part = part.replace(/~1/g, '/').replace(/~0/g, '~');
551+
current = current[part];
552+
if (!current) {
553+
return true;
554+
}
555+
const id = getSchemaId(current);
556+
if (isString(id) && id.charAt(0) !== '#') {
557+
let resolvedUri = id;
558+
if (contextService && !/^[A-Za-z][A-Za-z0-9+\-.+]*:\/.*/.test(id)) {
559+
resolvedUri = contextService.resolveRelativePath(id, currentBaseHandle.uri);
560+
}
561+
resolvedUri = normalizeId(resolvedUri);
562+
currentBaseHandle = this.getOrAddSchemaHandle(resolvedUri);
563+
}
564+
return false;
565+
});
566+
return { section: current, baseHandle: currentBaseHandle };
567+
};
568+
538569
const findSchemaById = (schema: JSONSchema, handle: SchemaHandle, id: string) => {
539570
if (!handle.anchors) {
540571
handle.anchors = collectAnchors(schema);
@@ -584,16 +615,29 @@ export class JSONSchemaService implements IJSONSchemaService {
584615

585616
const mergeRef = (target: JSONSchema, sourceRoot: JSONSchema, sourceHandle: SchemaHandle, refSegment: string | undefined): void => {
586617
let section;
618+
let sectionBaseHandle = sourceHandle;
587619
if (refSegment === undefined || refSegment.length === 0) {
588620
section = sourceRoot;
589621
} else if (refSegment.charAt(0) === '/') {
590622
// A $ref to a JSON Pointer (i.e #/definitions/foo)
591-
section = findSectionByJSONPointer(sourceRoot, refSegment);
623+
// Track $id base changes along the path so inner $refs resolve correctly.
624+
({ section, baseHandle: sectionBaseHandle } = findSectionAndBase(sourceRoot, refSegment, sourceHandle));
592625
} else {
593626
// A $ref to a sub-schema with an $id (i.e #hello)
594627
section = findSchemaById(sourceRoot, sourceHandle, refSegment);
595628
}
596629
if (section) {
630+
// If the found section contains a $ref that needs to be resolved
631+
// relative to a different base (e.g. it's inside a schema with $id),
632+
// pre-resolve it now so it carries the correct base URI context.
633+
if (section.$ref && sectionBaseHandle !== sourceHandle) {
634+
const innerRef = section.$ref;
635+
const innerSegments = innerRef.split('#', 2);
636+
if (innerSegments[0].length > 0 && contextService && !/^[A-Za-z][A-Za-z0-9+\-.+]*:\/.*/.test(innerSegments[0])) {
637+
section.$ref = contextService.resolveRelativePath(innerSegments[0], sectionBaseHandle.uri) +
638+
(innerSegments[1] !== undefined ? '#' + innerSegments[1] : '');
639+
}
640+
}
597641
const reservedKeys = new Set(['$ref', '$defs', 'definitions', '$schema', '$id', 'id']);
598642

599643
// In JSON Schema draft-04 through draft-07, $ref completely overrides any sibling keywords.
@@ -696,10 +740,12 @@ export class JSONSchemaService implements IJSONSchemaService {
696740
delete schema.$ref;
697741
if (segments[0].length > 0) {
698742
// This is a reference to an external schema (like "foo.json" or "foo.json#/bar")
699-
// According to JSON Schema spec, $ref is resolved against the parent's base URI,
700-
// not against a sibling $id at the same level
701-
// So we use currentBaseHandle (parent's), not newBaseHandle (which might include sibling $id)
702-
openPromises.push(resolveExternalLink(schema, segments[0], segments[1], currentBaseHandle));
743+
// Per JSON Schema spec, $ref is resolved against the current base URI.
744+
// If this schema has its own $id (sibling case), the $ref should resolve
745+
// against the parent's base, not the sibling $id. Otherwise, use the
746+
// nearest ancestor's base (newBaseHandle).
747+
const refBase = (newBase === schema) ? currentBaseHandle : newBaseHandle;
748+
openPromises.push(resolveExternalLink(schema, segments[0], segments[1], refBase));
703749
return;
704750
} else {
705751
// This is an internal reference (like "#/definitions/foo")
@@ -748,8 +794,8 @@ export class JSONSchemaService implements IJSONSchemaService {
748794

749795
// Collect anchor from this node
750796
// In draft-04/06/07, anchors are defined via $id/#fragment (e.g., "$id": "#myanchor")
751-
// $anchor was introduced in draft-2019-09, so only use it for 2019-09 and later
752-
const fragmentAnchor = isString(id) && id.charAt(0) === '#' ? id.substring(1) : undefined;
797+
// In 2019-09+, $id fragments are no longer anchors; $anchor is used instead
798+
const fragmentAnchor = (schemaDraft === undefined || schemaDraft < SchemaDraft.v2019_09) && isString(id) && id.charAt(0) === '#' ? id.substring(1) : undefined;
753799
const dollarAnchor = (schemaDraft === undefined || schemaDraft >= SchemaDraft.v2019_09) ? node.$anchor : undefined;
754800
const anchor = fragmentAnchor ?? dollarAnchor;
755801
if (anchor) {

src/services/vocabularies.ts

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { SchemaDraft } from '../jsonLanguageTypes';
7+
68
/*
79
* Checks if a keyword is enabled based on the active vocabularies.
810
* If no vocabulary constraints are present, all keywords are enabled.
@@ -108,35 +110,43 @@ export function isKeywordEnabled(
108110
* - format-assertion: format must be validated and can produce errors
109111
*
110112
* For backwards compatibility:
111-
* - If no vocabularies are specified, format asserts (pre-2019-09 behavior)
113+
* - If no vocabularies are specified and no explicit 2019-09+ draft, format asserts
112114
* - 2019-09 format vocabulary is annotation-only
113115
* - 2020-12 format-assertion vocabulary asserts
114116
* - 2020-12 format-annotation vocabulary does not assert
115117
*
116118
* @param activeVocabularies Map of active vocabulary URIs to required flag, or undefined if no constraints
119+
* @param schemaDraft The explicitly declared schema draft (only set when $schema was present), or undefined
117120
* @returns true if format validation should produce errors, false if annotation-only
118121
*/
119-
export function isFormatAssertionEnabled(activeVocabularies?: Map<string, boolean>): boolean {
120-
// If no vocabulary constraints or empty vocabulary map, assert for backward compatibility with older schemas
121-
if (!activeVocabularies || activeVocabularies.size === 0) {
122-
return true;
123-
}
122+
export function isFormatAssertionEnabled(activeVocabularies?: Map<string, boolean>, schemaDraft?: SchemaDraft): boolean {
123+
// If vocabulary constraints are present, use them to determine format assertion
124+
if (activeVocabularies && activeVocabularies.size > 0) {
125+
// 2020-12 format-assertion explicitly enables format validation errors
126+
if (activeVocabularies.has('https://json-schema.org/draft/2020-12/vocab/format-assertion')) {
127+
return true;
128+
}
124129

125-
// 2020-12 format-assertion explicitly enables format validation errors
126-
if (activeVocabularies.has('https://json-schema.org/draft/2020-12/vocab/format-assertion')) {
127-
return true;
128-
}
130+
// 2019-09 format vocabulary is annotation-only per spec
131+
if (activeVocabularies.has('https://json-schema.org/draft/2019-09/vocab/format')) {
132+
return false;
133+
}
129134

130-
// 2019-09 format vocabulary is annotation-only per spec
131-
if (activeVocabularies.has('https://json-schema.org/draft/2019-09/vocab/format')) {
135+
// 2020-12 format-annotation is annotation-only, no assertion
136+
if (activeVocabularies.has('https://json-schema.org/draft/2020-12/vocab/format-annotation')) {
137+
return false;
138+
}
139+
140+
// No format vocabulary active - no assertion
132141
return false;
133142
}
134143

135-
// 2020-12 format-annotation is annotation-only, no assertion
136-
if (activeVocabularies.has('https://json-schema.org/draft/2020-12/vocab/format-annotation')) {
144+
// No vocabulary constraints:
145+
// For explicitly declared 2019-09+ schemas, format is annotation-only by default per spec.
146+
// For older drafts or no explicit $schema, format asserts for backward compatibility.
147+
if (schemaDraft !== undefined && schemaDraft >= SchemaDraft.v2019_09) {
137148
return false;
138149
}
139150

140-
// No format vocabulary active - no assertion
141-
return false;
151+
return true;
142152
}

0 commit comments

Comments
 (0)