Skip to content

Commit c3d8763

Browse files
author
Keegan Caruso
committed
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.
1 parent b39120a commit c3d8763

5 files changed

Lines changed: 115 additions & 41 deletions

File tree

package-lock.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
"@typescript-eslint/eslint-plugin": "^8.57.0",
2525
"@typescript-eslint/parser": "^8.57.0",
2626
"eslint": "^9.39.4",
27-
"json-schema-test-suite": "https://github.com/json-schema-org/JSON-Schema-Test-Suite.git#69acf52990b004240839ae19b4bec8fb01d50876",
27+
"json-schema-test-suite": "https://github.com/json-schema-org/JSON-Schema-Test-Suite.git#92acb61eb772a932c077d5ffa634ded719d2d738",
2828
"rimraf": "^6.1.3",
2929
"typescript": "^5.9.3"
3030
},

src/parser/jsonParser.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,10 +455,11 @@ function validate(n: ASTNode | undefined, schema: JSONSchema, validationResult:
455455
? schemaStack.find(hasRecursiveAnchor)
456456
: currentResourceRoot ?? schemaRoots[0];
457457

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

src/services/jsonSchemaService.ts

Lines changed: 104 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -279,10 +279,16 @@ export class JSONSchemaService implements IJSONSchemaService {
279279
private promiseConstructor: PromiseConstructor;
280280

281281
private static traverseSchemaProperties(node: JSONSchema, callback: (schema: JSONSchema) => void): void {
282-
const singleSchemaProps = ['additionalItems', 'additionalProperties', 'not', 'contains',
283-
'propertyNames', 'if', 'then', 'else', 'unevaluatedItems', 'unevaluatedProperties', 'items'] as const;
282+
// `$defs`/`definitions` are visited first so that a reusable resource they
283+
// contain (which may itself carry a `$ref` chain) begins resolving before an
284+
// `if`/`then`/`else`/`items`/… sibling references it. During asynchronous
285+
// `$ref` resolution the referenced resource must be fully assembled before a
286+
// referrer merges it, otherwise the referrer captures a half-resolved snapshot
287+
// (e.g. a `$dynamicRef` chain reached through `then: { $ref: "numberList" }`).
284288
const schemaMapProps = ['definitions', '$defs', 'properties', 'patternProperties',
285289
'dependencies', 'dependentSchemas'] as const;
290+
const singleSchemaProps = ['additionalItems', 'additionalProperties', 'not', 'contains',
291+
'propertyNames', 'if', 'then', 'else', 'unevaluatedItems', 'unevaluatedProperties', 'items'] as const;
286292
const schemaArrayProps = ['anyOf', 'allOf', 'oneOf', 'prefixItems'] as const;
287293

288294
const visitValue = (value: JSONSchemaRef | JSONSchemaRef[]): void => {
@@ -295,20 +301,20 @@ export class JSONSchemaService implements IJSONSchemaService {
295301
}
296302
};
297303

298-
for (const prop of singleSchemaProps) {
299-
const propValue = node[prop];
300-
if (propValue) {
301-
visitValue(propValue);
302-
}
303-
}
304-
305304
for (const prop of schemaMapProps) {
306305
const map = node[prop];
307306
if (map && typeof map === 'object') {
308307
Object.values(map).forEach(visitValue);
309308
}
310309
}
311310

311+
for (const prop of singleSchemaProps) {
312+
const propValue = node[prop];
313+
if (propValue) {
314+
visitValue(propValue);
315+
}
316+
}
317+
312318
for (const prop of schemaArrayProps) {
313319
const propValue = node[prop];
314320
if (propValue) {
@@ -500,6 +506,10 @@ export class JSONSchemaService implements IJSONSchemaService {
500506
const resolveErrors: SchemaDiagnostic[] = schemaToResolve.errors.slice(0);
501507
const schema = schemaToResolve.schema;
502508

509+
// External documents whose embedded $id resources have already been registered
510+
// as resolvable handles, so we only do it once per referenced document.
511+
const embeddedRegisteredFor = new Set<string>();
512+
503513
const schemaDraft = schema.$schema ? getSchemaDraftFromId(schema.$schema) : undefined;
504514
if (schemaDraft === SchemaDraft.v3) {
505515
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 {
556566
path.split('/').some((part) => {
557567
part = part.replace(/~1/g, '/').replace(/~0/g, '~');
558568
current = current[part];
559-
return !current;
569+
// A boolean `false` is a valid schema, not a "missing" section, so only
570+
// stop on genuinely absent values (undefined/null).
571+
return current === undefined || current === null;
560572
});
561573
return current;
562574
};
@@ -575,7 +587,9 @@ export class JSONSchemaService implements IJSONSchemaService {
575587
path.split('/').some((part) => {
576588
part = part.replace(/~1/g, '/').replace(/~0/g, '~');
577589
current = current[part];
578-
if (!current) {
590+
// A boolean `false` is a valid schema, not a "missing" section, so only
591+
// stop on genuinely absent values (undefined/null).
592+
if (current === undefined || current === null) {
579593
return true;
580594
}
581595
const id = getSchemaId(current);
@@ -601,6 +615,30 @@ export class JSONSchemaService implements IJSONSchemaService {
601615

602616
const getSchemaId = (schema: JSONSchema): string | undefined => schema.$id || schema.id;
603617

618+
// Fold a source resource's $dynamicAnchor names into a target node's dynamic
619+
// map, creating it on demand. Existing entries win, so the outermost resource
620+
// in a dynamic scope retains precedence — this is what lets a $dynamicAnchor in
621+
// an outer resource override an inner (referenced) one during the validation
622+
// walk. Only the `dynamic` map is folded: the `local` map must stay per-resource
623+
// (it resolves an internal $dynamicRef's initial target within its own lexical
624+
// resource), so a sibling/referenced resource's identically-named anchor must
625+
// not leak into it.
626+
const unionAnchorMaps = (target: MergedJSONSchema, srcMaps: AnchorMaps | undefined): void => {
627+
if (srcMaps === undefined) {
628+
return;
629+
}
630+
let maps = target.$anchorMaps;
631+
if (maps === undefined) {
632+
maps = { dynamic: new Map<string, JSONSchema>(), local: new Map<string, JSONSchema>() };
633+
setHidden(target, '$anchorMaps', maps);
634+
}
635+
for (const [name, node] of srcMaps.dynamic) {
636+
if (!maps.dynamic.has(name)) {
637+
maps.dynamic.set(name, node);
638+
}
639+
}
640+
};
641+
604642
const merge = (target: MergedJSONSchema, section: any): void => {
605643
for (const key in section) {
606644
if (!section.hasOwnProperty(key) || key === 'id' || key === '$id') {
@@ -649,23 +687,7 @@ export class JSONSchemaService implements IJSONSchemaService {
649687
// dynamic-scope resolution — this is what lets a $dynamicAnchor defined in
650688
// an external (or $ref'd sub-) resource override a base default. Existing
651689
// entries are kept so the outermost resource retains precedence.
652-
if (src.$anchorMaps !== undefined) {
653-
let maps = dst.$anchorMaps;
654-
if (maps === undefined) {
655-
maps = { dynamic: new Map<string, JSONSchema>(), local: new Map<string, JSONSchema>() };
656-
setHidden(target, '$anchorMaps', maps);
657-
}
658-
for (const [name, node] of src.$anchorMaps.dynamic) {
659-
if (!maps.dynamic.has(name)) {
660-
maps.dynamic.set(name, node);
661-
}
662-
}
663-
for (const [name, node] of src.$anchorMaps.local) {
664-
if (!maps.local.has(name)) {
665-
maps.local.set(name, node);
666-
}
667-
}
668-
}
690+
unionAnchorMaps(dst, src.$anchorMaps);
669691
};
670692

671693
type SchemaKeyword = keyof JSONSchema;
@@ -699,6 +721,7 @@ export class JSONSchemaService implements IJSONSchemaService {
699721
];
700722
const containsBoundKeywords: readonly SchemaKeyword[] = ['minContains', 'maxContains'];
701723
const conditionalBranchKeywords: readonly SchemaKeyword[] = ['then', 'else'];
724+
const arrayApplicatorKeywords: readonly SchemaKeyword[] = ['items', 'prefixItems', 'additionalItems'];
702725
const uses2020_12ArrayAnnotations = schemaDraft === undefined || schemaDraft >= SchemaDraft.v2020_12;
703726

704727
// Some keywords only make sense relative to adjacent keywords in the same schema object.
@@ -725,10 +748,29 @@ export class JSONSchemaService implements IJSONSchemaService {
725748
return true;
726749
}
727750

751+
// Reverse of the above: the *referencing* schema declares unevaluatedItems
752+
// while the referenced schema constrains items positionally and may contain
753+
// internal self-references (`$ref: "#"`). Flattening would pull the referenced
754+
// items into the referencing scope, so those self-references would inherit the
755+
// referencing unevaluatedItems. Isolate so the referenced resource keeps its
756+
// own annotation scope.
757+
if (hasKeyword(referencingSchema, 'unevaluatedItems') && hasAnyKeyword(referencedSchema, arrayApplicatorKeywords)) {
758+
return true;
759+
}
760+
728761
if (hasKeyword(referencedSchema, 'contains') && hasAnyKeyword(referencingSchema, containsBoundKeywords)) {
729762
return true;
730763
}
731764

765+
// Both the referenced and referencing schemas constrain array items
766+
// (tuple `items`, or 2020-12 `items`/`prefixItems`). A flatten-merge can
767+
// only keep one `items`, silently dropping the other; isolate into an
768+
// allOf so both position constraints apply and item-evaluation
769+
// annotations (for unevaluatedItems) accumulate across both.
770+
if (Array.isArray(referencedSchema.items) && Array.isArray(referencingSchema.items)) {
771+
return true;
772+
}
773+
732774
if (hasAnyKeyword(referencedSchema, containsBoundKeywords) && hasKeyword(referencingSchema, 'contains')) {
733775
return true;
734776
}
@@ -759,6 +801,12 @@ export class JSONSchemaService implements IJSONSchemaService {
759801
// A $ref to a sub-schema with an $id (i.e #hello)
760802
section = findSchemaById(sourceRoot, sourceHandle, refSegment);
761803
}
804+
// A boolean is a valid JSON Schema: `true` accepts everything ({}) and
805+
// `false` rejects everything ({ not: {} }). Normalize so it merges like any
806+
// other schema (and isn't mistaken for an unresolved section below).
807+
if (typeof section === 'boolean') {
808+
section = section ? {} : { not: {} };
809+
}
762810
if (section) {
763811
// If the found section contains a $ref that needs to be resolved
764812
// relative to a different base (e.g. it's inside a schema with $id),
@@ -776,6 +824,16 @@ export class JSONSchemaService implements IJSONSchemaService {
776824
}
777825
}
778826
const reservedKeys = new Set(['$ref', '$defs', 'definitions', '$schema', '$id', 'id']);
827+
// Keywords that must stay on the wrapper rather than move into the allOf
828+
// sibling. Scope-anchor keywords ($recursiveAnchor/$dynamicAnchor) keep this
829+
// resource discoverable as a $recursiveRef/$dynamicRef bookending target.
830+
// The unevaluated* annotations must observe the results of *all* in-place
831+
// applicators (including the $ref'd schema in allOf[0]); leaving them on the
832+
// wrapper lets them see the aggregated annotations rather than only the
833+
// sibling's own evaluations.
834+
const keepOnWrapperKeys = new Set([
835+
'$recursiveAnchor', '$dynamicAnchor', 'unevaluatedItems', 'unevaluatedProperties'
836+
]);
779837

780838
// In JSON Schema draft-04 through draft-07, $ref completely overrides any sibling keywords.
781839
// Starting in 2019-09, sibling keywords are processed alongside $ref.
@@ -797,9 +855,10 @@ export class JSONSchemaService implements IJSONSchemaService {
797855
const siblingSchema: JSONSchema = {};
798856
const refSchema = { ...section };
799857

800-
// Move all existing properties from target to siblingSchema
858+
// Move all existing properties from target to siblingSchema, except
859+
// keywords that must remain on the wrapper resource.
801860
for (const key in target) {
802-
if (target.hasOwnProperty(key) && !reservedKeys.has(key)) {
861+
if (target.hasOwnProperty(key) && !reservedKeys.has(key) && !keepOnWrapperKeys.has(key)) {
803862
const k = key as keyof JSONSchema;
804863
siblingSchema[k] = target[k] as any;
805864
delete target[k];
@@ -831,6 +890,15 @@ export class JSONSchemaService implements IJSONSchemaService {
831890
const errorMessage = refSegment ? l10n.t('Problems loading reference \'{0}\': {1}', refSegment, error.message) : error.message;
832891
resolveErrors.push(toDiagnostic(errorMessage, error.code, uri));
833892
}
893+
// A referenced document may itself embed subschemas with their own
894+
// absolute $id. Register those as resolvable handles (once per document)
895+
// so a nested $ref by that $id resolves to the embedded resource instead
896+
// of being (mis)loaded as a standalone document. registerEmbeddedSchemas
897+
// only runs for the root document otherwise.
898+
if (!embeddedRegisteredFor.has(uri)) {
899+
embeddedRegisteredFor.add(uri);
900+
registerEmbeddedSchemas(unresolvedSchema.schema, uri);
901+
}
834902
// 2020-12: collect the referenced document's own resource anchor maps
835903
// (once) before merging, so its $dynamicAnchor declarations can join the
836904
// dynamic scope and its $dynamicRef nodes get their lexical resource
@@ -840,6 +908,12 @@ export class JSONSchemaService implements IJSONSchemaService {
840908
collectDynamicAnchors(unresolvedSchema.schema);
841909
}
842910
mergeRef(node, unresolvedSchema.schema, referencedHandle, refSegment);
911+
// Referencing a fragment of another resource (e.g. "other#/$defs/x")
912+
// still *enters* that resource's dynamic scope, so its $dynamicAnchor
913+
// declarations must join `node`'s scope even though only the sub-schema
914+
// was merged. (For a whole-document ref the merge above already did this,
915+
// since the merged section is the resource root; this is a no-op then.)
916+
unionAnchorMaps(node as MergedJSONSchema, (unresolvedSchema.schema as MergedJSONSchema).$anchorMaps);
843917
return resolveRefs(node, unresolvedSchema.schema, referencedHandle);
844918
});
845919
};

src/test/jsonSchemaTestSuite.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,5 @@ function initializeTests() {
198198

199199
});
200200
}
201-
const skippedTests = new Set<string>([
202-
]);
201+
const skippedTests = new Set<string>([]);
203202
initializeTests();

0 commit comments

Comments
 (0)