You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/07/22 15:29:07 UTC

(maven-resolver) branch the-re-entrancy-detection-added-in-pr-1957-merge created (now 7e80b4681)

This is an automated email from the ASF dual-hosted git repository.

gnodet pushed a change to branch the-re-entrancy-detection-added-in-pr-1957-merge
in repository https://gitbox.apache.org/repos/asf/maven-resolver.git


      at 7e80b4681 Extend re-entrancy detection to cover broken trace chains and skip managed dependency validation

This branch includes the following new commits:

     new 7e80b4681 Extend re-entrancy detection to cover broken trace chains and skip managed dependency validation

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(maven-resolver) 01/01: Extend re-entrancy detection to cover broken trace chains and skip managed dependency validation

Posted by gn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

gnodet pushed a commit to branch the-re-entrancy-detection-added-in-pr-1957-merge
in repository https://gitbox.apache.org/repos/asf/maven-resolver.git

commit 7e80b46812aaf999a73cc2c89265fe0e05860e46
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Wed Jul 22 15:28:55 2026 +0000

    Extend re-entrancy detection to cover broken trace chains and skip managed dependency validation
    
    The trace-based re-entrancy detection added in PR #1957 does not cover all
    code paths. Maven 4's RequestTraceHelper converts between Maven API traces
    and resolver RequestTrace objects, creating a fresh trace chain that loses
    the REPOSITORY_SYSTEM_CALL marker. This causes MavenValidator to reject
    re-entrant calls that carry uninterpolated property expressions.
    
    This commit addresses the gap with two complementary fixes:
    
    1. Session-scoped re-entrancy detection: Each public RepositorySystem method
       now maintains a depth counter in SessionData via enterSessionScope()/
       exitSessionScope(). When the counter is > 0 on entry, the call is
       detected as re-entrant regardless of whether the RequestTrace chain
       carries the marker. This catches cases where consumers rebuild the
       trace chain from a different tracing system.
    
    2. Skip managed dependency validation in validateCollectRequest(): Managed
       dependencies are declarative constraints that only take effect when a
       matching dependency is encountered during collection. Validating them
       eagerly rejects valid builds where a BOM imports managed dependencies
       with uninterpolated property expressions (e.g. ${osgi.version}) that
       are never actually used. If a managed dependency IS matched and its
       coordinates are invalid, the error surfaces during version/artifact
       resolution.
    
    Together, these changes allow Maven PR #12481 to remove the consumer-side
    workarounds for uninterpolated expressions in DefaultArtifactDescriptorReader,
    ArtifactDescriptorReaderDelegate, DefaultProjectDependenciesResolver, and
    DefaultDependencyResolver.
    
    Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
 .../internal/impl/DefaultRepositorySystem.java     | 260 +++++++++++++++------
 .../impl/DefaultRepositorySystemValidator.java     |  14 +-
 .../DefaultRepositorySystemReentrancyTest.java     | 168 ++++++++++++-
 3 files changed, 363 insertions(+), 79 deletions(-)

diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultRepositorySystem.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultRepositorySystem.java
index 915df50f6..71cc7b342 100644
--- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultRepositorySystem.java
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultRepositorySystem.java
@@ -220,55 +220,88 @@ public class DefaultRepositorySystem implements RepositorySystem {
     public VersionResult resolveVersion(RepositorySystemSession session, VersionRequest request)
             throws VersionResolutionException {
         requireNonNull(request, "request cannot be null");
-        if (!isReentrant(request.getTrace())) {
+        Runnable exitGuard = null;
+        if (!isReentrant(request.getTrace(), session)) {
             validateSession(session);
             repositorySystemValidator.validateVersionRequest(session, request);
             request.setTrace(stampReentrancyMarker(request.getTrace()));
+            exitGuard = enterSessionScope(session);
+        }
+        try {
+            return versionResolver.resolveVersion(session, request);
+        } finally {
+            if (exitGuard != null) {
+                exitGuard.run();
+            }
         }
-        return versionResolver.resolveVersion(session, request);
     }
 
     @Override
     public VersionRangeResult resolveVersionRange(RepositorySystemSession session, VersionRangeRequest request)
             throws VersionRangeResolutionException {
         requireNonNull(request, "request cannot be null");
-        if (!isReentrant(request.getTrace())) {
+        Runnable exitGuard = null;
+        if (!isReentrant(request.getTrace(), session)) {
             validateSession(session);
             repositorySystemValidator.validateVersionRangeRequest(session, request);
             request.setTrace(stampReentrancyMarker(request.getTrace()));
+            exitGuard = enterSessionScope(session);
+        }
+        try {
+            return versionRangeResolver.resolveVersionRange(session, request);
+        } finally {
+            if (exitGuard != null) {
+                exitGuard.run();
+            }
         }
-        return versionRangeResolver.resolveVersionRange(session, request);
     }
 
     @Override
     public ArtifactDescriptorResult readArtifactDescriptor(
             RepositorySystemSession session, ArtifactDescriptorRequest request) throws ArtifactDescriptorException {
         requireNonNull(request, "request cannot be null");
-        boolean outermost = !isReentrant(request.getTrace());
+        boolean outermost = !isReentrant(request.getTrace(), session);
+        Runnable exitGuard = null;
         if (outermost) {
             validateSession(session);
             repositorySystemValidator.validateArtifactDescriptorRequest(session, request);
             request.setTrace(stampReentrancyMarker(request.getTrace()));
+            exitGuard = enterSessionScope(session);
         }
-        ArtifactDescriptorResult descriptorResult = artifactDescriptorReader.readArtifactDescriptor(session, request);
-        if (outermost) {
-            for (ArtifactDecorator decorator : Utils.getArtifactDecorators(session, artifactDecoratorFactories)) {
-                descriptorResult.setArtifact(decorator.decorateArtifact(descriptorResult));
+        try {
+            ArtifactDescriptorResult descriptorResult =
+                    artifactDescriptorReader.readArtifactDescriptor(session, request);
+            if (outermost) {
+                for (ArtifactDecorator decorator : Utils.getArtifactDecorators(session, artifactDecoratorFactories)) {
+                    descriptorResult.setArtifact(decorator.decorateArtifact(descriptorResult));
+                }
+            }
+            return descriptorResult;
+        } finally {
+            if (exitGuard != null) {
+                exitGuard.run();
             }
         }
-        return descriptorResult;
     }
 
     @Override
     public ArtifactResult resolveArtifact(RepositorySystemSession session, ArtifactRequest request)
             throws ArtifactResolutionException {
         requireNonNull(request, "request cannot be null");
-        if (!isReentrant(request.getTrace())) {
+        Runnable exitGuard = null;
+        if (!isReentrant(request.getTrace(), session)) {
             validateSession(session);
             repositorySystemValidator.validateArtifactRequests(session, Collections.singleton(request));
             request.setTrace(stampReentrancyMarker(request.getTrace()));
+            exitGuard = enterSessionScope(session);
+        }
+        try {
+            return artifactResolver.resolveArtifact(session, request);
+        } finally {
+            if (exitGuard != null) {
+                exitGuard.run();
+            }
         }
-        return artifactResolver.resolveArtifact(session, request);
     }
 
     @Override
@@ -282,14 +315,22 @@ public class DefaultRepositorySystem implements RepositorySystem {
                 .filter(Objects::nonNull)
                 .findFirst()
                 .orElse(null);
-        if (!isReentrant(firstTrace)) {
+        Runnable exitGuard = null;
+        if (!isReentrant(firstTrace, session)) {
             validateSession(session);
             repositorySystemValidator.validateArtifactRequests(session, requests);
             for (ArtifactRequest request : requests) {
                 request.setTrace(stampReentrancyMarker(request.getTrace()));
             }
+            exitGuard = enterSessionScope(session);
+        }
+        try {
+            return artifactResolver.resolveArtifacts(session, requests);
+        } finally {
+            if (exitGuard != null) {
+                exitGuard.run();
+            }
         }
-        return artifactResolver.resolveArtifacts(session, requests);
     }
 
     @Override
@@ -302,96 +343,120 @@ public class DefaultRepositorySystem implements RepositorySystem {
                 .filter(Objects::nonNull)
                 .findFirst()
                 .orElse(null);
-        if (!isReentrant(firstTrace)) {
+        Runnable exitGuard = null;
+        if (!isReentrant(firstTrace, session)) {
             validateSession(session);
             repositorySystemValidator.validateMetadataRequests(session, requests);
             for (MetadataRequest request : requests) {
                 request.setTrace(stampReentrancyMarker(request.getTrace()));
             }
+            exitGuard = enterSessionScope(session);
+        }
+        try {
+            return metadataResolver.resolveMetadata(session, requests);
+        } finally {
+            if (exitGuard != null) {
+                exitGuard.run();
+            }
         }
-        return metadataResolver.resolveMetadata(session, requests);
     }
 
     @Override
     public CollectResult collectDependencies(RepositorySystemSession session, CollectRequest request)
             throws DependencyCollectionException {
         requireNonNull(request, "request cannot be null");
-        if (!isReentrant(request.getTrace())) {
+        Runnable exitGuard = null;
+        if (!isReentrant(request.getTrace(), session)) {
             validateSession(session);
             repositorySystemValidator.validateCollectRequest(session, request);
             request.setTrace(stampReentrancyMarker(request.getTrace()));
+            exitGuard = enterSessionScope(session);
+        }
+        try {
+            return dependencyCollector.collectDependencies(session, request);
+        } finally {
+            if (exitGuard != null) {
+                exitGuard.run();
+            }
         }
-        return dependencyCollector.collectDependencies(session, request);
     }
 
     @Override
     public DependencyResult resolveDependencies(RepositorySystemSession session, DependencyRequest request)
             throws DependencyResolutionException {
         requireNonNull(request, "request cannot be null");
-        if (!isReentrant(request.getTrace())) {
+        Runnable exitGuard = null;
+        if (!isReentrant(request.getTrace(), session)) {
             validateSession(session);
             repositorySystemValidator.validateDependencyRequest(session, request);
             request.setTrace(stampReentrancyMarker(request.getTrace()));
+            exitGuard = enterSessionScope(session);
         }
-        RequestTrace trace = RequestTrace.newChild(request.getTrace(), request);
-
-        DependencyResult result = new DependencyResult(request);
-
-        DependencyCollectionException dce = null;
-        ArtifactResolutionException are = null;
+        try {
+            RequestTrace trace = RequestTrace.newChild(request.getTrace(), request);
+
+            DependencyResult result = new DependencyResult(request);
+
+            DependencyCollectionException dce = null;
+            ArtifactResolutionException are = null;
+
+            if (request.getRoot() != null) {
+                result.setRoot(request.getRoot());
+            } else if (request.getCollectRequest() != null) {
+                CollectResult collectResult;
+                try {
+                    request.getCollectRequest().setTrace(trace);
+                    collectResult = dependencyCollector.collectDependencies(session, request.getCollectRequest());
+                } catch (DependencyCollectionException e) {
+                    dce = e;
+                    collectResult = e.getResult();
+                }
+                result.setRoot(collectResult.getRoot());
+                result.setCycles(collectResult.getCycles());
+                result.setCollectExceptions(collectResult.getExceptions());
+            } else {
+                throw new NullPointerException("dependency node and collect request cannot be null");
+            }
 
-        if (request.getRoot() != null) {
-            result.setRoot(request.getRoot());
-        } else if (request.getCollectRequest() != null) {
-            CollectResult collectResult;
+            final List<DependencyNode> dependencyNodes =
+                    doFlattenDependencyNodes(session, result.getRoot(), request.getFilter());
+
+            final List<ArtifactRequest> requests = dependencyNodes.stream()
+                    .map(n -> {
+                        if (n.getDependency() != null) {
+                            ArtifactRequest artifactRequest = new ArtifactRequest(n);
+                            artifactRequest.setTrace(trace);
+                            return artifactRequest;
+                        } else {
+                            return null;
+                        }
+                    })
+                    .filter(Objects::nonNull)
+                    .collect(Collectors.toList());
+            List<ArtifactResult> results;
             try {
-                request.getCollectRequest().setTrace(trace);
-                collectResult = dependencyCollector.collectDependencies(session, request.getCollectRequest());
-            } catch (DependencyCollectionException e) {
-                dce = e;
-                collectResult = e.getResult();
+                results = artifactResolver.resolveArtifacts(session, requests);
+            } catch (ArtifactResolutionException e) {
+                are = e;
+                results = e.getResults();
             }
-            result.setRoot(collectResult.getRoot());
-            result.setCycles(collectResult.getCycles());
-            result.setCollectExceptions(collectResult.getExceptions());
-        } else {
-            throw new NullPointerException("dependency node and collect request cannot be null");
-        }
+            result.setDependencyNodeResults(dependencyNodes);
+            result.setArtifactResults(results);
 
-        final List<DependencyNode> dependencyNodes =
-                doFlattenDependencyNodes(session, result.getRoot(), request.getFilter());
-
-        final List<ArtifactRequest> requests = dependencyNodes.stream()
-                .map(n -> {
-                    if (n.getDependency() != null) {
-                        ArtifactRequest artifactRequest = new ArtifactRequest(n);
-                        artifactRequest.setTrace(trace);
-                        return artifactRequest;
-                    } else {
-                        return null;
-                    }
-                })
-                .filter(Objects::nonNull)
-                .collect(Collectors.toList());
-        List<ArtifactResult> results;
-        try {
-            results = artifactResolver.resolveArtifacts(session, requests);
-        } catch (ArtifactResolutionException e) {
-            are = e;
-            results = e.getResults();
-        }
-        result.setDependencyNodeResults(dependencyNodes);
-        result.setArtifactResults(results);
+            updateNodesWithResolvedArtifacts(results);
 
-        updateNodesWithResolvedArtifacts(results);
+            if (dce != null) {
+                throw new DependencyResolutionException(result, dce);
+            } else if (are != null) {
+                throw new DependencyResolutionException(result, are);
+            }
 
-        if (dce != null) {
-            throw new DependencyResolutionException(result, dce);
-        } else if (are != null) {
-            throw new DependencyResolutionException(result, are);
+            return result;
+        } finally {
+            if (exitGuard != null) {
+                exitGuard.run();
+            }
         }
-
-        return result;
     }
 
     @Override
@@ -557,6 +622,24 @@ public class DefaultRepositorySystem implements RepositorySystem {
         }
     }
 
+    /**
+     * Session data key for the re-entrancy depth counter. This supplements the
+     * {@link RequestTrace}-based detection for consumers that rebuild the trace chain
+     * from a different tracing system (e.g. Maven 4's {@code RequestTraceHelper} converts
+     * between Maven API traces and resolver traces, losing the
+     * {@link #REPOSITORY_SYSTEM_CALL} marker).
+     * <p>
+     * The value stored under this key is an {@link AtomicInteger} tracking how many
+     * {@code RepositorySystem} public methods are currently on the call stack for this
+     * session. A value &gt; 0 on entry means the call is re-entrant.
+     */
+    private static final Object SESSION_REENTRY_DEPTH_KEY = new Object() {
+        @Override
+        public String toString() {
+            return "RepositorySystem.reentryDepth";
+        }
+    };
+
     /**
      * Stamps the {@link #REPOSITORY_SYSTEM_CALL} re-entrancy marker into the trace chain
      * while preserving the original trace tip data. The marker is inserted <em>below</em>
@@ -587,6 +670,41 @@ public class DefaultRepositorySystem implements RepositorySystem {
         return false;
     }
 
+    /**
+     * Combined re-entrancy check using both {@link RequestTrace} ancestry and session-scoped
+     * depth tracking. Either mechanism detecting re-entrancy is sufficient to skip validation.
+     * <p>
+     * The trace-based check is the primary mechanism and works when callers properly propagate
+     * traces. The session-based check is a fallback for callers that rebuild the trace chain
+     * from a different tracing system (e.g. Maven 4's trace conversion loses the resolver's
+     * re-entrancy marker).
+     *
+     * @param trace   the current request trace (may be {@code null})
+     * @param session the current repository system session
+     * @return {@code true} if this is a re-entrant call, {@code false} if it is the outermost call
+     */
+    private static boolean isReentrant(RequestTrace trace, RepositorySystemSession session) {
+        return isReentrant(trace) || getReentryDepth(session).get() > 0;
+    }
+
+    /**
+     * Increments the session-scoped re-entrancy depth counter. Must be called on every outermost
+     * entry into a public {@code RepositorySystem} method, and the returned {@link Runnable} must
+     * be invoked in a {@code finally} block to decrement the counter on exit.
+     *
+     * @param session the current repository system session
+     * @return a {@link Runnable} that decrements the depth counter when invoked
+     */
+    private static Runnable enterSessionScope(RepositorySystemSession session) {
+        AtomicInteger depth = getReentryDepth(session);
+        depth.incrementAndGet();
+        return depth::decrementAndGet;
+    }
+
+    private static AtomicInteger getReentryDepth(RepositorySystemSession session) {
+        return (AtomicInteger) session.getData().computeIfAbsent(SESSION_REENTRY_DEPTH_KEY, () -> new AtomicInteger(0));
+    }
+
     private void validateSession(RepositorySystemSession session) {
         requireNonNull(session, "repository system session cannot be null");
         invalidSession(session.getLocalRepositoryManager(), "local repository manager");
diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultRepositorySystemValidator.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultRepositorySystemValidator.java
index 15c85f20d..109ccf5f0 100644
--- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultRepositorySystemValidator.java
+++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/DefaultRepositorySystemValidator.java
@@ -202,13 +202,13 @@ public class DefaultRepositorySystemValidator implements RepositorySystemValidat
                     exceptions.add(e);
                 }
             }
-            for (Dependency managedDependency : request.getManagedDependencies()) {
-                try {
-                    validator.validateDependency(managedDependency);
-                } catch (Exception e) {
-                    exceptions.add(e);
-                }
-            }
+            // Managed dependencies are intentionally NOT validated here. They are declarative
+            // constraints (version/scope/exclusion overrides) that only take effect when a
+            // matching dependency is encountered during collection. Validating them eagerly
+            // rejects valid builds where a BOM imports managed dependencies with uninterpolated
+            // property expressions (e.g. ${osgi.version}) that are never actually used.
+            // If a managed dependency IS matched and its coordinates are invalid, the error
+            // will surface naturally during version resolution or artifact resolution.
             for (RemoteRepository repository : request.getRepositories()) {
                 try {
                     validator.validateRemoteRepository(repository);
diff --git a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultRepositorySystemReentrancyTest.java b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultRepositorySystemReentrancyTest.java
index 6aa9e10de..eff6a51da 100644
--- a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultRepositorySystemReentrancyTest.java
+++ b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/DefaultRepositorySystemReentrancyTest.java
@@ -21,11 +21,15 @@ package org.eclipse.aether.internal.impl;
 import java.util.Collections;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 
 import org.eclipse.aether.DefaultRepositorySystemSession;
 import org.eclipse.aether.RepositorySystemSession;
 import org.eclipse.aether.RequestTrace;
 import org.eclipse.aether.artifact.DefaultArtifact;
+import org.eclipse.aether.collection.CollectRequest;
+import org.eclipse.aether.collection.CollectResult;
+import org.eclipse.aether.graph.Dependency;
 import org.eclipse.aether.impl.ArtifactResolver;
 import org.eclipse.aether.impl.DependencyCollector;
 import org.eclipse.aether.impl.Deployer;
@@ -57,7 +61,7 @@ import static org.mockito.Mockito.mock;
 public class DefaultRepositorySystemReentrancyTest {
 
     /**
-     * A validator that rejects any artifact whose version contains "${".
+     * A validator that rejects any artifact or dependency whose version contains "${".
      * This simulates Maven's MavenValidator rejecting uninterpolated expressions.
      */
     private static final ValidatorFactory EXPRESSION_REJECTING_VALIDATOR_FACTORY = session -> new Validator() {
@@ -67,6 +71,11 @@ public class DefaultRepositorySystemReentrancyTest {
                 throw new IllegalArgumentException("Uninterpolated expression in version: " + artifact.getVersion());
             }
         }
+
+        @Override
+        public void validateDependency(Dependency dependency) {
+            validateArtifact(dependency.getArtifact());
+        }
     };
 
     private DefaultRepositorySystem system;
@@ -303,4 +312,161 @@ public class DefaultRepositorySystemReentrancyTest {
         system.resolveVersionRange(session, innerRequest);
         assertEquals(countBefore, validationCount.get(), "Re-entrant call should skip validation");
     }
+
+    @Test
+    void sessionScopedDetectionSkipsValidationWhenTraceChainIsBroken() throws Exception {
+        // Simulates Maven 4's flow where the model builder converts between Maven API traces
+        // and resolver traces via RequestTraceHelper, losing the REPOSITORY_SYSTEM_CALL marker.
+        //
+        // The DependencyCollector, called from within collectDependencies, re-enters
+        // RepositorySystem.resolveVersionRange with a FRESH trace (no marker in ancestry)
+        // and an uninterpolated expression like ${project.version}. Without session-scoped
+        // detection, the validator would reject the expression; with it, the call is
+        // detected as re-entrant and validation is skipped.
+        AtomicReference<DefaultRepositorySystem> systemRef = new AtomicReference<>();
+
+        DependencyCollector reentrantCollector = (s, request) -> {
+            // Inside collectDependencies, simulate a re-entrant call with a FRESH trace
+            // (no marker in ancestry — this is the broken path) and an uninterpolated
+            // expression. If session-scoped detection fails, the validator rejects this.
+            VersionRangeRequest innerRequest = new VersionRangeRequest(
+                    new DefaultArtifact("g:inner:${project.version}"), Collections.emptyList(), null);
+            // Fresh trace — no REPOSITORY_SYSTEM_CALL marker (simulates Maven's trace conversion)
+            innerRequest.setTrace(RequestTrace.newChild(null, "MavenModelResolver"));
+            try {
+                systemRef.get().resolveVersionRange(s, innerRequest);
+            } catch (Exception e) {
+                fail("Re-entrant call with broken trace chain should succeed via session-scoped detection: " + e);
+            }
+            return new CollectResult(request);
+        };
+
+        DefaultRepositorySystem strictSystem = new DefaultRepositorySystem(
+                new StubVersionResolver(),
+                new StubVersionRangeResolver(),
+                mock(ArtifactResolver.class),
+                mock(MetadataResolver.class),
+                new StubArtifactDescriptorReader(),
+                reentrantCollector,
+                mock(Installer.class),
+                mock(Deployer.class),
+                mock(LocalRepositoryProvider.class),
+                new StubSyncContextFactory(),
+                new DefaultRemoteRepositoryManager(
+                        new DefaultUpdatePolicyAnalyzer(),
+                        new DefaultChecksumPolicyProvider(),
+                        new DefaultRepositoryKeyFunctionFactory()),
+                new DefaultRepositorySystemLifecycle(),
+                Collections.emptyMap(),
+                new DefaultRepositorySystemValidator(
+                        Collections.singletonList(EXPRESSION_REJECTING_VALIDATOR_FACTORY)));
+        systemRef.set(strictSystem);
+
+        // The outermost collectDependencies call should succeed, and the inner
+        // resolveVersionRange call (with broken trace and uninterpolated expression)
+        // should be detected as re-entrant via the session-scoped depth counter.
+        CollectRequest collectRequest = new CollectRequest();
+        collectRequest.setRootArtifact(new DefaultArtifact("g:root:1.0"));
+
+        assertDoesNotThrow(
+                () -> strictSystem.collectDependencies(session, collectRequest),
+                "Inner call with broken trace chain should succeed via session-scoped detection");
+    }
+
+    @Test
+    void collectDependenciesAcceptsManagedDepsWithUninterpolatedExpressions() throws Exception {
+        // Reproduces the MavenITgh12305 scenario: a BOM imports managed dependencies
+        // with uninterpolated expressions like ${osgi.version}. These managed dependencies
+        // should be accepted because they are declarative constraints that may never be used.
+        DependencyCollector passThroughCollector = (s, request) -> new CollectResult(request);
+
+        DefaultRepositorySystem strictSystem = new DefaultRepositorySystem(
+                new StubVersionResolver(),
+                new StubVersionRangeResolver(),
+                mock(ArtifactResolver.class),
+                mock(MetadataResolver.class),
+                new StubArtifactDescriptorReader(),
+                passThroughCollector,
+                mock(Installer.class),
+                mock(Deployer.class),
+                mock(LocalRepositoryProvider.class),
+                new StubSyncContextFactory(),
+                new DefaultRemoteRepositoryManager(
+                        new DefaultUpdatePolicyAnalyzer(),
+                        new DefaultChecksumPolicyProvider(),
+                        new DefaultRepositoryKeyFunctionFactory()),
+                new DefaultRepositorySystemLifecycle(),
+                Collections.emptyMap(),
+                new DefaultRepositorySystemValidator(
+                        Collections.singletonList(EXPRESSION_REJECTING_VALIDATOR_FACTORY)));
+
+        // Build a CollectRequest with a managed dependency that has an uninterpolated version
+        // (like ${osgi.version} from a BOM import)
+        CollectRequest collectRequest = new CollectRequest();
+        collectRequest.setRootArtifact(new DefaultArtifact("g:project:1.0"));
+        collectRequest.addManagedDependency(
+                new Dependency(new DefaultArtifact("org.example:lib-with-undefined-version:${undefined.version}"), "provided"));
+
+        // This should succeed — managed dependencies are not validated because they are
+        // declarative constraints, not actual resolution targets
+        assertDoesNotThrow(
+                () -> strictSystem.collectDependencies(session, collectRequest),
+                "Managed dependencies with uninterpolated expressions should be accepted");
+    }
+
+    @Test
+    void collectDependenciesStillRejectsInvalidDirectDependencies() throws Exception {
+        // Verify that direct dependencies (not managed) ARE still validated
+        DependencyCollector passThroughCollector = (s, request) -> new CollectResult(request);
+
+        DefaultRepositorySystem strictSystem = new DefaultRepositorySystem(
+                new StubVersionResolver(),
+                new StubVersionRangeResolver(),
+                mock(ArtifactResolver.class),
+                mock(MetadataResolver.class),
+                new StubArtifactDescriptorReader(),
+                passThroughCollector,
+                mock(Installer.class),
+                mock(Deployer.class),
+                mock(LocalRepositoryProvider.class),
+                new StubSyncContextFactory(),
+                new DefaultRemoteRepositoryManager(
+                        new DefaultUpdatePolicyAnalyzer(),
+                        new DefaultChecksumPolicyProvider(),
+                        new DefaultRepositoryKeyFunctionFactory()),
+                new DefaultRepositorySystemLifecycle(),
+                Collections.emptyMap(),
+                new DefaultRepositorySystemValidator(
+                        Collections.singletonList(EXPRESSION_REJECTING_VALIDATOR_FACTORY)));
+
+        // Direct dependency with uninterpolated expression should still be rejected
+        CollectRequest collectRequest = new CollectRequest();
+        collectRequest.setRootArtifact(new DefaultArtifact("g:project:1.0"));
+        collectRequest.addDependency(
+                new Dependency(new DefaultArtifact("org.example:lib:${undefined.version}"), "compile"));
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> strictSystem.collectDependencies(session, collectRequest),
+                "Direct dependencies with uninterpolated expressions should still be rejected");
+    }
+
+    @Test
+    void sessionScopedDepthIsProperlyDecrementedOnExit() throws Exception {
+        // Verify that the session-scoped depth counter is properly decremented after
+        // a RepositorySystem call completes, so independent calls are still validated
+        VersionRangeRequest request1 =
+                new VersionRangeRequest(new DefaultArtifact("g:a:1.0"), Collections.emptyList(), null);
+        VersionRangeRequest request2 =
+                new VersionRangeRequest(new DefaultArtifact("g:b:2.0"), Collections.emptyList(), null);
+
+        system.resolveVersionRange(session, request1);
+        int countAfterFirst = validationCount.get();
+        assertEquals(1, countAfterFirst, "First call should validate");
+
+        // Second independent call (no shared trace) should also validate
+        // because the depth counter should have been decremented on exit
+        system.resolveVersionRange(session, request2);
+        assertEquals(2, validationCount.get(), "Second independent call should also validate");
+    }
 }