@@ -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 } ;
0 commit comments