@@ -422,6 +422,7 @@ static CompiledRegistry compileWorkStack(JsonValue initialJson,
422422 Deque <java .net .URI > workStack = new ArrayDeque <>();
423423 Map <java .net .URI , CompiledRoot > built = new NormalizedUriMap (new LinkedHashMap <>());
424424 Set <java .net .URI > active = new HashSet <>();
425+ Map <java .net .URI , java .net .URI > parentMap = new HashMap <>();
425426
426427 LOG .finest (() -> "compileWorkStack: initialized workStack=" + workStack + ", built=" + built + ", active=" + active );
427428
@@ -481,7 +482,7 @@ static CompiledRegistry compileWorkStack(JsonValue initialJson,
481482 if (refToken instanceof RefToken .RemoteRef remoteRef ) {
482483 LOG .finest (() -> "compileWorkStack: processing RemoteRef object=" + remoteRef + ", base=" + remoteRef .baseUri () + ", target=" + remoteRef .targetUri ());
483484 java .net .URI targetDocUri = normalizeUri (finalCurrentUri , remoteRef .targetUri ().toString ());
484- boolean scheduled = scheduleRemoteIfUnseen (finalWorkStack , finalBuilt , targetDocUri );
485+ boolean scheduled = scheduleRemoteIfUnseen (finalWorkStack , finalBuilt , parentMap , finalCurrentUri , targetDocUri );
485486 LOG .finer (() -> "compileWorkStack: remote ref scheduled=" + scheduled + ", target=" + targetDocUri );
486487 }
487488 }, built , options , compileOptions );
@@ -650,12 +651,23 @@ public String pointer() {
650651 }
651652
652653 /// Schedule remote document for compilation if not seen before
653- static boolean scheduleRemoteIfUnseen (Deque <java .net .URI > workStack , Map <java .net .URI , CompiledRoot > built , java .net .URI targetDocUri ) {
654+ static boolean scheduleRemoteIfUnseen (Deque <java .net .URI > workStack ,
655+ Map <java .net .URI , CompiledRoot > built ,
656+ Map <java .net .URI , java .net .URI > parentMap ,
657+ java .net .URI currentDocUri ,
658+ java .net .URI targetDocUri ) {
654659 LOG .finer (() -> "scheduleRemoteIfUnseen: target=" + targetDocUri + ", workStack.size=" + workStack .size () + ", built.size=" + built .size ());
655660 LOG .finest (() -> "scheduleRemoteIfUnseen: targetDocUri object=" + targetDocUri + ", scheme=" + targetDocUri .getScheme () + ", host=" + targetDocUri .getHost () + ", path=" + targetDocUri .getPath ());
656661 LOG .finest (() -> "scheduleRemoteIfUnseen: workStack object=" + workStack + ", contents=" + workStack .stream ().map (Object ::toString ).collect (java .util .stream .Collectors .joining (", " , "[" , "]" )));
657662 LOG .finest (() -> "scheduleRemoteIfUnseen: built map object=" + built + ", keys=" + built .keySet () + ", size=" + built .size ());
658663
664+ // Detect remote cycles by walking parent chain
665+ if (formsRemoteCycle (parentMap , currentDocUri , targetDocUri )) {
666+ String cycleMessage = "ERROR: CYCLE: remote $ref cycle current=" + currentDocUri + ", target=" + targetDocUri ;
667+ LOG .severe (() -> cycleMessage );
668+ throw new IllegalArgumentException (cycleMessage );
669+ }
670+
659671 // Check if already built or already in work stack
660672 boolean alreadyBuilt = built .containsKey (targetDocUri );
661673 boolean inWorkStack = workStack .contains (targetDocUri );
@@ -667,13 +679,37 @@ static boolean scheduleRemoteIfUnseen(Deque<java.net.URI> workStack, Map<java.ne
667679 return false ;
668680 }
669681
682+ // Track parent chain for cycle detection before scheduling child
683+ parentMap .putIfAbsent (targetDocUri , currentDocUri );
684+
670685 // Add to work stack
671686 workStack .push (targetDocUri );
672687 LOG .finer (() -> "scheduleRemoteIfUnseen: scheduled remote document: " + targetDocUri );
673688 LOG .finest (() -> "scheduleRemoteIfUnseen: workStack after push=" + workStack + ", contents=" + workStack .stream ().map (Object ::toString ).collect (java .util .stream .Collectors .joining (", " , "[" , "]" )));
674689 return true ;
675690 }
676691
692+ private static boolean formsRemoteCycle (Map <java .net .URI , java .net .URI > parentMap ,
693+ java .net .URI currentDocUri ,
694+ java .net .URI targetDocUri ) {
695+ if (currentDocUri .equals (targetDocUri )) {
696+ return true ;
697+ }
698+
699+ java .net .URI cursor = currentDocUri ;
700+ while (cursor != null ) {
701+ java .net .URI parent = parentMap .get (cursor );
702+ if (parent == null ) {
703+ break ;
704+ }
705+ if (parent .equals (targetDocUri )) {
706+ return true ;
707+ }
708+ cursor = parent ;
709+ }
710+ return false ;
711+ }
712+
677713 /// Detect and throw on compile-time cycles
678714 static void detectAndThrowCycle (Set <java .net .URI > active , java .net .URI docUri , String pathTrail ) {
679715 LOG .finest (() -> "detectAndThrowCycle: active set=" + active + ", docUri=" + docUri + ", pathTrail='" + pathTrail + "'" );
@@ -1464,6 +1500,7 @@ private static final class Session {
14641500 final Map <String , JsonSchema > definitions = new LinkedHashMap <>();
14651501 final Map <String , JsonSchema > compiledByPointer = new LinkedHashMap <>();
14661502 final Map <String , JsonValue > rawByPointer = new LinkedHashMap <>();
1503+ final Map <java .net .URI , java .net .URI > parentMap = new LinkedHashMap <>();
14671504 JsonSchema currentRootSchema ;
14681505 Options currentOptions ;
14691506 long totalFetchedBytes ;
@@ -1473,7 +1510,8 @@ private static final class Session {
14731510 private static java .net .URI stripFragment (java .net .URI uri ) {
14741511 String s = uri .toString ();
14751512 int i = s .indexOf ('#' );
1476- return i >= 0 ? java .net .URI .create (s .substring (0 , i )) : uri ;
1513+ java .net .URI base = i >= 0 ? java .net .URI .create (s .substring (0 , i )) : uri ;
1514+ return base .normalize ();
14771515 }
14781516 // removed static mutable state; state now lives in Session
14791517
@@ -1953,13 +1991,26 @@ private static JsonSchema compileInternalWithContext(Session session, JsonValue
19531991 // Handle remote refs by adding to work stack
19541992 if (refToken instanceof RefToken .RemoteRef remoteRef ) {
19551993 LOG .finer (() -> "Remote ref detected: " + remoteRef .targetUri ());
1956- // Get document URI without fragment
19571994 java .net .URI targetDocUri = stripFragment (remoteRef .targetUri ());
1958- if (!seenUris .contains (targetDocUri )) {
1995+ LOG .fine (() -> "Remote ref scheduling from docUri=" + docUri + " to target=" + targetDocUri );
1996+ LOG .finest (() -> "Remote ref parentMap before cycle check: " + session .parentMap );
1997+ if (formsRemoteCycle (session .parentMap , docUri , targetDocUri )) {
1998+ String cycleMessage = "ERROR: CYCLE: remote $ref cycle current=" + docUri + ", target=" + targetDocUri ;
1999+ LOG .severe (() -> cycleMessage );
2000+ throw new IllegalArgumentException (cycleMessage );
2001+ }
2002+ boolean alreadySeen = seenUris .contains (targetDocUri );
2003+ LOG .finest (() -> "Remote ref alreadySeen=" + alreadySeen + " for target=" + targetDocUri );
2004+ if (!alreadySeen ) {
19592005 workStack .push (new WorkItem (targetDocUri ));
19602006 seenUris .add (targetDocUri );
2007+ session .parentMap .putIfAbsent (targetDocUri , docUri );
19612008 LOG .finer (() -> "Added to work stack: " + targetDocUri );
2009+ } else {
2010+ session .parentMap .putIfAbsent (targetDocUri , docUri );
2011+ LOG .finer (() -> "Remote ref already scheduled or compiled: " + targetDocUri );
19622012 }
2013+ LOG .finest (() -> "Remote ref parentMap after scheduling: " + session .parentMap );
19632014 LOG .finest (() -> "compileInternalWithContext: Creating RefSchema for remote ref " + remoteRef .targetUri ());
19642015
19652016 LOG .fine (() -> "Creating RefSchema for remote ref " + remoteRef .targetUri () +
0 commit comments