Skip to content

Commit f822189

Browse files
author
Keegan Caruso
committed
JSONSchemaService: Enhance sibling scope isolation for referenced schemas with additional tests
1 parent e99e4a7 commit f822189

File tree

2 files changed

+155
-10
lines changed

2 files changed

+155
-10
lines changed

src/services/jsonSchemaService.ts

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -604,13 +604,82 @@ export class JSONSchemaService implements IJSONSchemaService {
604604
}
605605
};
606606

607-
// Check if schemas need scope isolation for unevaluated keywords
608-
const needsScopeIsolation = (section: JSONSchema, target: JSONSchema): boolean => {
609-
const hasUnevaluated = section.unevaluatedProperties !== undefined || section.unevaluatedItems !== undefined;
610-
const hasSiblingEvaluation = target.properties !== undefined || target.patternProperties !== undefined ||
611-
target.additionalProperties !== undefined || target.allOf !== undefined || target.anyOf !== undefined ||
612-
target.oneOf !== undefined || target.if !== undefined;
613-
return hasUnevaluated && hasSiblingEvaluation;
607+
type SchemaKeyword = keyof JSONSchema;
608+
609+
const hasKeyword = (schema: JSONSchema, keyword: SchemaKeyword): boolean => schema[keyword] !== undefined;
610+
const hasAnyKeyword = (schema: JSONSchema, keywords: readonly SchemaKeyword[]): boolean =>
611+
keywords.some(keyword => hasKeyword(schema, keyword));
612+
613+
const objectPropertyKeywords: readonly SchemaKeyword[] = ['properties', 'patternProperties'];
614+
const objectEvaluatorKeywords: readonly SchemaKeyword[] = [
615+
...objectPropertyKeywords,
616+
'additionalProperties',
617+
'dependentSchemas',
618+
'allOf',
619+
'anyOf',
620+
'oneOf',
621+
'if',
622+
'then',
623+
'else'
624+
];
625+
const arrayEvaluatorKeywords: readonly SchemaKeyword[] = [
626+
'items',
627+
'additionalItems',
628+
'prefixItems',
629+
'allOf',
630+
'anyOf',
631+
'oneOf',
632+
'if',
633+
'then',
634+
'else'
635+
];
636+
const containsBoundKeywords: readonly SchemaKeyword[] = ['minContains', 'maxContains'];
637+
const conditionalBranchKeywords: readonly SchemaKeyword[] = ['then', 'else'];
638+
const uses2020_12ArrayAnnotations = schemaDraft === undefined || schemaDraft >= SchemaDraft.v2020_12;
639+
640+
// Some keywords only make sense relative to adjacent keywords in the same schema object.
641+
// If they live behind $ref, sibling keywords on the referencing schema must not be
642+
// flattened into the same scope.
643+
const needsScopeIsolation = (referencedSchema: JSONSchema, referencingSchema: JSONSchema): boolean => {
644+
const hasSiblingObjectProperties = hasAnyKeyword(referencingSchema, objectPropertyKeywords);
645+
if (hasKeyword(referencedSchema, 'additionalProperties') && hasSiblingObjectProperties) {
646+
return true;
647+
}
648+
649+
if (hasKeyword(referencedSchema, 'additionalItems') && Array.isArray(referencingSchema.items)) {
650+
return true;
651+
}
652+
653+
const hasSiblingObjectEvaluators = hasAnyKeyword(referencingSchema, objectEvaluatorKeywords);
654+
if (hasKeyword(referencedSchema, 'unevaluatedProperties') && hasSiblingObjectEvaluators) {
655+
return true;
656+
}
657+
658+
const hasSiblingArrayEvaluators = hasAnyKeyword(referencingSchema, arrayEvaluatorKeywords) ||
659+
(uses2020_12ArrayAnnotations && hasKeyword(referencingSchema, 'contains'));
660+
if (hasKeyword(referencedSchema, 'unevaluatedItems') && hasSiblingArrayEvaluators) {
661+
return true;
662+
}
663+
664+
if (hasKeyword(referencedSchema, 'contains') && hasAnyKeyword(referencingSchema, containsBoundKeywords)) {
665+
return true;
666+
}
667+
668+
if (hasAnyKeyword(referencedSchema, containsBoundKeywords) && hasKeyword(referencingSchema, 'contains')) {
669+
return true;
670+
}
671+
672+
if (hasKeyword(referencedSchema, 'if') && hasAnyKeyword(referencingSchema, conditionalBranchKeywords)) {
673+
return true;
674+
}
675+
676+
if (hasAnyKeyword(referencedSchema, conditionalBranchKeywords) && hasKeyword(referencingSchema, 'if')) {
677+
return true;
678+
}
679+
680+
return uses2020_12ArrayAnnotations &&
681+
hasKeyword(referencedSchema, 'items') &&
682+
hasKeyword(referencingSchema, 'prefixItems');
614683
};
615684

616685
const mergeRef = (target: JSONSchema, sourceRoot: JSONSchema, sourceHandle: SchemaHandle, refSegment: string | undefined): void => {
@@ -653,9 +722,9 @@ export class JSONSchemaService implements IJSONSchemaService {
653722
}
654723
merge(target, section);
655724
} else if (needsScopeIsolation(section, target)) {
656-
// In JSON Schema 2019-09 or greater, $ref creates a new scope when it has sibling keywords.
657-
// When the $ref'd schema has unevaluatedProperties/unevaluatedItems, it should not
658-
// see properties/items evaluated by sibling keywords.
725+
// In JSON Schema 2019-09 or greater, $ref creates a new scope when sibling
726+
// keywords would otherwise change the meaning of same-object-dependent keywords
727+
// from the referenced schema.
659728
// To achieve this, we wrap the $ref in an allOf when needed.
660729
const siblingSchema: JSONSchema = {};
661730
const refSchema = { ...section };

src/test/schema.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2961,6 +2961,82 @@ suite('JSON Schema', () => {
29612961
});
29622962
});
29632963

2964+
suite('$ref sibling scope isolation', () => {
2965+
test('referenced additionalProperties should not see sibling properties', async function () {
2966+
const schema: JSONSchema = {
2967+
$schema: 'https://json-schema.org/draft/2019-09/schema',
2968+
$ref: '#/$defs/base',
2969+
properties: {
2970+
foo: {
2971+
type: 'string'
2972+
}
2973+
},
2974+
$defs: {
2975+
base: {
2976+
type: 'object',
2977+
additionalProperties: false
2978+
}
2979+
}
2980+
};
2981+
2982+
const ls = getLanguageService({});
2983+
const { textDoc, jsonDoc } = toDocument('{ "foo": "ok" }');
2984+
const validation = await ls.doValidation(textDoc, jsonDoc, {}, schema);
2985+
2986+
assert.strictEqual(validation.length, 1);
2987+
assert.ok(validation[0].message.includes('Property foo is not allowed.'));
2988+
});
2989+
2990+
test('referenced additionalItems should not pair with sibling tuple items', async function () {
2991+
const schema: JSONSchema = {
2992+
$schema: 'https://json-schema.org/draft/2019-09/schema',
2993+
$ref: '#/$defs/base',
2994+
items: [
2995+
{
2996+
type: 'string'
2997+
}
2998+
],
2999+
$defs: {
3000+
base: {
3001+
type: 'array',
3002+
additionalItems: false
3003+
}
3004+
}
3005+
};
3006+
3007+
const ls = getLanguageService({});
3008+
const { textDoc, jsonDoc } = toDocument('["x", "y"]');
3009+
const validation = await ls.doValidation(textDoc, jsonDoc, {}, schema);
3010+
3011+
assert.strictEqual(validation.length, 0);
3012+
});
3013+
3014+
test('referenced unevaluatedItems should not see sibling tuple items', async function () {
3015+
const schema: JSONSchema = {
3016+
$schema: 'https://json-schema.org/draft/2019-09/schema',
3017+
$ref: '#/$defs/base',
3018+
items: [
3019+
{
3020+
type: 'string'
3021+
}
3022+
],
3023+
$defs: {
3024+
base: {
3025+
type: 'array',
3026+
unevaluatedItems: false
3027+
}
3028+
}
3029+
};
3030+
3031+
const ls = getLanguageService({});
3032+
const { textDoc, jsonDoc } = toDocument('["x"]');
3033+
const validation = await ls.doValidation(textDoc, jsonDoc, {}, schema);
3034+
3035+
assert.strictEqual(validation.length, 1);
3036+
assert.ok(validation[0].message.includes('Item does not match any validation rule from the array.'));
3037+
});
3038+
});
3039+
29643040
suite('$id fragment anchors', () => {
29653041
test('$id fragment should be an anchor in draft-07', async function () {
29663042
const schemaRequestService = newMockRequestService();

0 commit comments

Comments
 (0)