From 22c84cb88b8e1702a53c9aa00066542f873ff0b8 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Thu, 10 Sep 2026 09:52:22 +0000 Subject: [PATCH 1/4] [MNG-8766] Add WorkspaceReader SPI to maven-api-spi for IDE integration Three major Java IDEs (IntelliJ IDEA, Eclipse m2e, Apache NetBeans) all override the internal PluginDependenciesResolver component for the same reason: they need their workspace reader to not participate in plugin resolution. Plugin realms are cached by DefaultPluginRealmCache (@Singleton) and cannot be purged within a session, so resolving plugins from the IDE workspace causes stale classloaders when workspace sources change. This commit introduces a proper SPI in maven-api-spi: org.apache.maven.api.spi.WorkspaceReader The interface uses Maven 4 API types exclusively (no maven-resolver-api dependency required), with three methods: - findArtifact(Artifact): Optional - findVersions(Artifact): List - isApplicableForPluginResolution(): boolean (default true) IDE integrators can implement this SPI and return false from isApplicableForPluginResolution() to opt their workspace reader out of plugin resolution, without touching any internal Maven component. Implementation: - SpiWorkspaceReaderAdapter bridges SPI implementations into the resolver workspace reader chain (added in DefaultMaven) - DefaultPluginDependenciesResolver filters out non-applicable readers when building plugin sessions - IT mng-8766 verifies that SPI readers with isApplicableForPluginResolution returning false are not called during plugin resolution --- .../apache/maven/api/spi/WorkspaceReader.java | 81 +++++++ .../java/org/apache/maven/DefaultMaven.java | 11 +- .../DefaultPluginDependenciesResolver.java | 28 +++ .../resolver/SpiWorkspaceReaderAdapter.java | 197 ++++++++++++++++++ .../maven/DefaultMavenSessionScopeTest.java | 4 +- .../MavenITmng8766SpiWorkspaceReaderTest.java | 74 +++++++ .../extension/pom.xml | 57 +++++ .../extensions/TestSpiWorkspaceReader.java | 57 +++++ .../project/.mvn/extensions.xml | 28 +++ .../project/pom.xml | 39 ++++ 10 files changed, 574 insertions(+), 2 deletions(-) create mode 100644 api/maven-api-spi/src/main/java/org/apache/maven/api/spi/WorkspaceReader.java create mode 100644 impl/maven-core/src/main/java/org/apache/maven/resolver/SpiWorkspaceReaderAdapter.java create mode 100644 its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8766SpiWorkspaceReaderTest.java create mode 100644 its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/pom.xml create mode 100644 its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/src/main/java/org/apache/maven/its/extensions/TestSpiWorkspaceReader.java create mode 100644 its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/.mvn/extensions.xml create mode 100644 its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/pom.xml diff --git a/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/WorkspaceReader.java b/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/WorkspaceReader.java new file mode 100644 index 000000000000..517f5a330379 --- /dev/null +++ b/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/WorkspaceReader.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.api.spi; + +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import org.apache.maven.api.Artifact; +import org.apache.maven.api.annotations.Consumer; +import org.apache.maven.api.annotations.Experimental; +import org.apache.maven.api.di.Named; + +/** + * SPI for IDE and tool integrators to provide workspace artifact resolution. + * + *

Implementations are discovered via DI and are automatically bridged into the + * resolver's workspace reader chain, replacing the need for the internal + * {@code @Named("ide")} resolver {@code WorkspaceReader} mechanism. + * + *

Implementations should be annotated with {@link Named} and registered as + * Maven core extensions (via {@code .mvn/extensions.xml}). + * + *

Unlike the legacy {@code org.eclipse.aether.repository.WorkspaceReader}, this SPI + * uses Maven 4 API types only (no maven-resolver-api dependency required). + * + * @since 4.1.0 + */ +@Experimental +@Consumer +@Named +public interface WorkspaceReader extends SpiService { + + /** + * Finds the path on disk for the given artifact in the workspace, if present. + * + * @param artifact the artifact to look up, never {@code null} + * @return the path to the artifact file, or empty if not found in workspace + */ + Optional findArtifact(Artifact artifact); + + /** + * Returns the list of available versions for the given artifact in the workspace. + * + * @param artifact the artifact to look up (version is ignored), never {@code null} + * @return list of available versions, may be empty + */ + List findVersions(Artifact artifact); + + /** + * Whether this workspace reader should participate in plugin resolution. + * + *

IDE workspace readers should return {@code false} here: plugin realms are cached + * by {@code DefaultPluginRealmCache} (which is {@code @Singleton}) and cannot be purged + * within a session, so resolving plugins from the workspace can lead to stale classloaders + * when workspace sources change. + * + *

Defaults to {@code true} (participates in plugin resolution). + * + * @return {@code true} if this reader should be consulted during plugin resolution + */ + default boolean isApplicableForPluginResolution() { + return true; + } +} diff --git a/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java b/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java index ba5ea30d1db0..b619f73ff180 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java +++ b/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java @@ -71,6 +71,7 @@ import org.apache.maven.project.MavenProject; import org.apache.maven.resolver.MavenChainedWorkspaceReader; import org.apache.maven.resolver.RepositorySystemSessionFactory; +import org.apache.maven.resolver.SpiWorkspaceReaderAdapter; import org.apache.maven.session.scope.internal.SessionScope; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.RepositorySystemSession.CloseableSession; @@ -111,6 +112,8 @@ public class DefaultMaven implements Maven { private final WorkspaceReader ideWorkspaceReader; + private final List spiWorkspaceReaders; + private final ProjectSelector projectSelector; @Inject @@ -126,7 +129,8 @@ public DefaultMaven( BuildResumptionDataRepository buildResumptionDataRepository, SuperPomProvider superPomProvider, DefaultSessionFactory defaultSessionFactory, - @Nullable @Named("ide") WorkspaceReader ideWorkspaceReader) { + @Nullable @Named("ide") WorkspaceReader ideWorkspaceReader, + List spiWorkspaceReaders) { this.lookup = lookup; this.eventCatapult = eventCatapult; this.legacySupport = legacySupport; @@ -137,6 +141,7 @@ public DefaultMaven( this.buildResumptionDataRepository = buildResumptionDataRepository; this.superPomProvider = superPomProvider; this.ideWorkspaceReader = ideWorkspaceReader; + this.spiWorkspaceReaders = spiWorkspaceReaders; this.defaultSessionFactory = defaultSessionFactory; this.projectSelector = new ProjectSelector(); // if necessary switch to DI } @@ -213,6 +218,10 @@ private MavenExecutionResult doExecute(MavenExecutionRequest request) { try { MavenChainedWorkspaceReader chainedWorkspaceReader = new MavenChainedWorkspaceReader(request.getWorkspaceReader(), ideWorkspaceReader); + // Add SPI workspace readers to the chain + for (org.apache.maven.api.spi.WorkspaceReader spiReader : spiWorkspaceReaders) { + chainedWorkspaceReader.addReader(new SpiWorkspaceReaderAdapter(spiReader)); + } try (CloseableSession closeableSession = newCloseableSession(request, chainedWorkspaceReader)) { MavenSession session = new MavenSession(closeableSession, request, result); session.setSession(defaultSessionFactory.newSession(session)); diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultPluginDependenciesResolver.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultPluginDependenciesResolver.java index a17bbbd0fc04..2e587e1bad4c 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultPluginDependenciesResolver.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultPluginDependenciesResolver.java @@ -36,6 +36,8 @@ import org.apache.maven.model.Dependency; import org.apache.maven.model.Plugin; import org.apache.maven.plugin.PluginResolutionException; +import org.apache.maven.resolver.MavenChainedWorkspaceReader; +import org.apache.maven.resolver.SpiWorkspaceReaderAdapter; import org.eclipse.aether.DefaultRepositorySystemSession; import org.eclipse.aether.RepositorySystem; import org.eclipse.aether.RepositorySystemSession; @@ -48,6 +50,7 @@ import org.eclipse.aether.graph.DependencyFilter; import org.eclipse.aether.graph.DependencyNode; import org.eclipse.aether.repository.RemoteRepository; +import org.eclipse.aether.repository.WorkspaceReader; import org.eclipse.aether.resolution.ArtifactDescriptorException; import org.eclipse.aether.resolution.ArtifactDescriptorRequest; import org.eclipse.aether.resolution.ArtifactDescriptorResult; @@ -109,6 +112,9 @@ public Artifact resolve(Plugin plugin, List repositories, Repo DefaultRepositorySystemSession pluginSession = new DefaultRepositorySystemSession(session); pluginSession.setArtifactDescriptorPolicy(new SimpleArtifactDescriptorPolicy(true, false)); + // Filter out SPI workspace readers that opt out of plugin resolution + filterWorkspaceReadersForPluginResolution(session, pluginSession); + ArtifactDescriptorRequest request = new ArtifactDescriptorRequest(pluginArtifact, repositories, REPOSITORY_CONTEXT); request.setTrace(trace); @@ -267,6 +273,9 @@ private DependencyResult resolveInternal( pluginSession.setDependencySelector(session.getDependencySelector()); pluginSession.setDependencyGraphTransformer(session.getDependencyGraphTransformer()); + // Filter out SPI workspace readers that opt out of plugin resolution + filterWorkspaceReadersForPluginResolution(session, pluginSession); + CollectRequest request = new CollectRequest(); request.setRequestContext(REPOSITORY_CONTEXT); request.setRepositories(repositories); @@ -307,4 +316,23 @@ private DependencyResult resolveInternal( RequestTraceHelper.exit(trace); } } + + /** + * Filters workspace readers in the plugin session, removing SPI workspace readers + * that have opted out of plugin resolution via + * {@link org.apache.maven.api.spi.WorkspaceReader#isApplicableForPluginResolution()}. + */ + private void filterWorkspaceReadersForPluginResolution( + RepositorySystemSession session, DefaultRepositorySystemSession pluginSession) { + WorkspaceReader workspaceReader = session.getWorkspaceReader(); + if (workspaceReader instanceof MavenChainedWorkspaceReader chainedReader) { + List filtered = chainedReader.getReaders().stream() + .filter(r -> !(r instanceof SpiWorkspaceReaderAdapter adapter) + || adapter.isApplicableForPluginResolution()) + .collect(Collectors.toList()); + if (filtered.size() != chainedReader.getReaders().size()) { + pluginSession.setWorkspaceReader(MavenChainedWorkspaceReader.of(filtered)); + } + } + } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/resolver/SpiWorkspaceReaderAdapter.java b/impl/maven-core/src/main/java/org/apache/maven/resolver/SpiWorkspaceReaderAdapter.java new file mode 100644 index 000000000000..d0f8309d8871 --- /dev/null +++ b/impl/maven-core/src/main/java/org/apache/maven/resolver/SpiWorkspaceReaderAdapter.java @@ -0,0 +1,197 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.resolver; + +import java.io.File; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import org.apache.maven.api.ArtifactCoordinates; +import org.apache.maven.api.Version; +import org.apache.maven.api.annotations.Nonnull; +import org.eclipse.aether.artifact.Artifact; +import org.eclipse.aether.repository.WorkspaceReader; +import org.eclipse.aether.repository.WorkspaceRepository; + +/** + * Bridge adapter that wraps an {@link org.apache.maven.api.spi.WorkspaceReader SPI WorkspaceReader} + * into a resolver {@link WorkspaceReader}. + * + *

This adapter translates between resolver {@link Artifact} and Maven API + * {@link org.apache.maven.api.Artifact} types, allowing SPI implementations to work + * with Maven 4 API types exclusively while being integrated into the resolver's + * workspace reader chain. + * + * @since 4.1.0 + */ +public class SpiWorkspaceReaderAdapter implements WorkspaceReader { + + private final org.apache.maven.api.spi.WorkspaceReader delegate; + private final WorkspaceRepository repository; + + public SpiWorkspaceReaderAdapter(org.apache.maven.api.spi.WorkspaceReader delegate) { + this.delegate = delegate; + this.repository = new WorkspaceRepository("spi-" + delegate.getClass().getSimpleName()); + } + + @Override + public WorkspaceRepository getRepository() { + return repository; + } + + @Override + public File findArtifact(Artifact artifact) { + Optional result = delegate.findArtifact(toApiArtifact(artifact)); + return result.map(Path::toFile).orElse(null); + } + + @Override + public List findVersions(Artifact artifact) { + return delegate.findVersions(toApiArtifact(artifact)); + } + + /** + * Whether the underlying SPI reader should participate in plugin resolution. + * + * @return {@code true} if applicable for plugin resolution + */ + public boolean isApplicableForPluginResolution() { + return delegate.isApplicableForPluginResolution(); + } + + /** + * Returns the underlying SPI workspace reader. + */ + public org.apache.maven.api.spi.WorkspaceReader getDelegate() { + return delegate; + } + + /** + * Creates a lightweight Maven API {@link org.apache.maven.api.Artifact} from a resolver artifact + * without requiring an active session. + */ + private static org.apache.maven.api.Artifact toApiArtifact(Artifact artifact) { + return new LightweightApiArtifact(artifact); + } + + /** + * A lightweight implementation of {@link org.apache.maven.api.Artifact} that wraps a resolver artifact + * for the purpose of passing artifact coordinates to SPI workspace readers. + */ + private static class LightweightApiArtifact implements org.apache.maven.api.Artifact { + private final Artifact artifact; + private final String key; + + LightweightApiArtifact(Artifact artifact) { + this.artifact = artifact; + this.key = getGroupId() + + ':' + + getArtifactId() + + ':' + + getExtension() + + (getClassifier().isEmpty() ? "" : ":" + getClassifier()) + + ':' + + artifact.getVersion(); + } + + @Override + public String key() { + return key; + } + + @Nonnull + @Override + public String getGroupId() { + return artifact.getGroupId(); + } + + @Nonnull + @Override + public String getArtifactId() { + return artifact.getArtifactId(); + } + + @Nonnull + @Override + public Version getVersion() { + return new StringVersion(artifact.getVersion()); + } + + @Nonnull + @Override + public Version getBaseVersion() { + return new StringVersion(artifact.getBaseVersion()); + } + + @Nonnull + @Override + public String getExtension() { + return artifact.getExtension(); + } + + @Nonnull + @Override + public String getClassifier() { + return artifact.getClassifier(); + } + + @Override + public boolean isSnapshot() { + return artifact.isSnapshot(); + } + + @Nonnull + @Override + public ArtifactCoordinates toCoordinates() { + throw new UnsupportedOperationException("Lightweight artifact wrapper does not support toCoordinates(); " + + "use Session.createArtifactCoordinates() instead"); + } + + @Override + public boolean equals(Object o) { + return o instanceof org.apache.maven.api.Artifact a && key.equals(a.key()); + } + + @Override + public int hashCode() { + return key.hashCode(); + } + + @Override + public String toString() { + return key; + } + } + + /** + * Simple {@link Version} implementation that wraps a version string. + */ + private record StringVersion(String version) implements Version { + @Override + public int compareTo(Version o) { + return version.compareTo(o.toString()); + } + + @Override + public String toString() { + return version; + } + } +} diff --git a/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenSessionScopeTest.java b/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenSessionScopeTest.java index 67d3eb3e6d21..d312f98c7712 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenSessionScopeTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenSessionScopeTest.java @@ -19,6 +19,7 @@ package org.apache.maven; import java.io.File; +import java.util.List; import org.apache.maven.api.services.Lookup; import org.apache.maven.execution.BuildResumptionAnalyzer; @@ -77,7 +78,8 @@ void testSessionScopeIsExitedOnWorkspaceReaderError() throws Exception { mock(BuildResumptionDataRepository.class), null, mock(DefaultSessionFactory.class), - null); + null, + List.of()); MavenExecutionResult result = defaultMaven.execute(request); diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8766SpiWorkspaceReaderTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8766SpiWorkspaceReaderTest.java new file mode 100644 index 000000000000..347c012c8cc7 --- /dev/null +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8766SpiWorkspaceReaderTest.java @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.it; + +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration test for the {@code WorkspaceReader} SPI in maven-api-spi. + * + *

Verifies that SPI workspace readers with {@code isApplicableForPluginResolution() == false} + * are discovered and active, but are not consulted during plugin resolution. + * + * @since 4.1.0 + */ +class MavenITmng8766SpiWorkspaceReaderTest extends AbstractMavenIntegrationTestCase { + + @Test + void testSpiWorkspaceReaderFilteredFromPluginResolution() throws Exception { + Path testDir = extractResources("mng-8766-spi-workspace-reader"); + + // First, install the extension + Verifier verifier = newVerifier(testDir.resolve("extension")); + verifier.addCliArgument("install"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + // Run the project that uses the extension — process-resources triggers plugin resolution + verifier = newVerifier(testDir.resolve("project")); + verifier.addCliArgument("process-resources"); + verifier.execute(); + verifier.verifyErrorFreeLog(); + + // Verify the SPI workspace reader was created (proves discovery works) + verifier.verifyTextInLog("[SPI-WR] created"); + + // The SPI workspace reader should be called for artifact resolution in the main session + // (e.g., during project dependency resolution, model building, etc.) + List logLines = verifier.loadLogLines(); + + // Verify it was NOT called for plugin resolution + // When isApplicableForPluginResolution() returns false, the reader is removed from + // the plugin session's workspace reader chain, so it should not see any findArtifact + // calls for plugins like maven-resources-plugin + boolean hasPluginCalls = logLines.stream() + .anyMatch(line -> + line.contains("[SPI-WR] findArtifact(") && line.contains("maven-resources-plugin")); + assertFalse( + hasPluginCalls, + "SPI workspace reader with isApplicableForPluginResolution()=false " + + "should NOT be called for plugin resolution"); + } +} diff --git a/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/pom.xml b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/pom.xml new file mode 100644 index 000000000000..2038996cd6e8 --- /dev/null +++ b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/pom.xml @@ -0,0 +1,57 @@ + + + + + org.apache.maven.its.mng8766 + spi-workspace-reader + 0.1 + jar + + Maven Integration Test :: spi-workspace-reader + SPI WorkspaceReader extension that opts out of plugin resolution + + + + org.apache.maven + maven-api-spi + 4.1.0-SNAPSHOT + provided + + + org.slf4j + slf4j-api + 2.0.16 + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + full + + + + + diff --git a/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/src/main/java/org/apache/maven/its/extensions/TestSpiWorkspaceReader.java b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/src/main/java/org/apache/maven/its/extensions/TestSpiWorkspaceReader.java new file mode 100644 index 000000000000..11022bb61c31 --- /dev/null +++ b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/src/main/java/org/apache/maven/its/extensions/TestSpiWorkspaceReader.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.maven.its.extensions; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.apache.maven.api.Artifact; +import org.apache.maven.api.di.Named; +import org.apache.maven.api.spi.WorkspaceReader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Named("test-ide") +public class TestSpiWorkspaceReader implements WorkspaceReader { + + private static final Logger log = LoggerFactory.getLogger(TestSpiWorkspaceReader.class); + + public TestSpiWorkspaceReader() { + log.info("[SPI-WR] created"); + } + + @Override + public Optional findArtifact(Artifact artifact) { + log.info("[SPI-WR] findArtifact({})", artifact.key()); + return Optional.empty(); + } + + @Override + public List findVersions(Artifact artifact) { + log.info("[SPI-WR] findVersions({})", artifact.key()); + return Collections.emptyList(); + } + + @Override + public boolean isApplicableForPluginResolution() { + return false; + } +} diff --git a/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/.mvn/extensions.xml b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/.mvn/extensions.xml new file mode 100644 index 000000000000..0b71d00e7a4c --- /dev/null +++ b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/.mvn/extensions.xml @@ -0,0 +1,28 @@ + + + + + + + org.apache.maven.its.mng8766 + spi-workspace-reader + 0.1 + + diff --git a/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/pom.xml b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/pom.xml new file mode 100644 index 000000000000..797799b4df65 --- /dev/null +++ b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/pom.xml @@ -0,0 +1,39 @@ + + + + + org.apache.maven.its.mng8766 + test + 0.1 + jar + + Maven Integration Test :: mng-8766 + Verify that SPI WorkspaceReader is used for dependency resolution but not for plugin resolution. + + + + junit + junit + 4.13.2 + test + + + + From d1d0447c7ade918bd75bb6087c42990b3614d23a Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Thu, 10 Sep 2026 11:32:53 +0000 Subject: [PATCH 2/4] fix: use dynamic lookup for SPI workspace readers SPI WorkspaceReader implementations loaded from core extensions are registered in the Maven 4 DI system (via @org.apache.maven.api.di.Named). Constructor injection via javax.inject cannot see these beans because they live in a different DI world. Switch to lookup.lookupList() which goes through PlexusContainer; the SisuDiBridgeModule bridges Maven 4 DI beans back into Guice/Sisu, making them visible to Plexus lookups. Also add positive assertion in the IT to verify the SPI workspace reader is actually consulted during regular artifact resolution. --- .../main/java/org/apache/maven/DefaultMaven.java | 14 +++++++------- .../apache/maven/DefaultMavenSessionScopeTest.java | 4 +--- .../it/MavenITmng8766SpiWorkspaceReaderTest.java | 5 +++++ 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java b/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java index b619f73ff180..5d444a149c1f 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java +++ b/impl/maven-core/src/main/java/org/apache/maven/DefaultMaven.java @@ -112,8 +112,6 @@ public class DefaultMaven implements Maven { private final WorkspaceReader ideWorkspaceReader; - private final List spiWorkspaceReaders; - private final ProjectSelector projectSelector; @Inject @@ -129,8 +127,7 @@ public DefaultMaven( BuildResumptionDataRepository buildResumptionDataRepository, SuperPomProvider superPomProvider, DefaultSessionFactory defaultSessionFactory, - @Nullable @Named("ide") WorkspaceReader ideWorkspaceReader, - List spiWorkspaceReaders) { + @Nullable @Named("ide") WorkspaceReader ideWorkspaceReader) { this.lookup = lookup; this.eventCatapult = eventCatapult; this.legacySupport = legacySupport; @@ -141,7 +138,6 @@ public DefaultMaven( this.buildResumptionDataRepository = buildResumptionDataRepository; this.superPomProvider = superPomProvider; this.ideWorkspaceReader = ideWorkspaceReader; - this.spiWorkspaceReaders = spiWorkspaceReaders; this.defaultSessionFactory = defaultSessionFactory; this.projectSelector = new ProjectSelector(); // if necessary switch to DI } @@ -218,8 +214,12 @@ private MavenExecutionResult doExecute(MavenExecutionRequest request) { try { MavenChainedWorkspaceReader chainedWorkspaceReader = new MavenChainedWorkspaceReader(request.getWorkspaceReader(), ideWorkspaceReader); - // Add SPI workspace readers to the chain - for (org.apache.maven.api.spi.WorkspaceReader spiReader : spiWorkspaceReaders) { + // Add SPI workspace readers to the chain — looked up dynamically so that + // implementations discovered from core extensions are included (extensions + // are loaded after the container is bootstrapped, so constructor injection + // would miss them). + for (org.apache.maven.api.spi.WorkspaceReader spiReader : + lookup.lookupList(org.apache.maven.api.spi.WorkspaceReader.class)) { chainedWorkspaceReader.addReader(new SpiWorkspaceReaderAdapter(spiReader)); } try (CloseableSession closeableSession = newCloseableSession(request, chainedWorkspaceReader)) { diff --git a/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenSessionScopeTest.java b/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenSessionScopeTest.java index d312f98c7712..67d3eb3e6d21 100644 --- a/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenSessionScopeTest.java +++ b/impl/maven-core/src/test/java/org/apache/maven/DefaultMavenSessionScopeTest.java @@ -19,7 +19,6 @@ package org.apache.maven; import java.io.File; -import java.util.List; import org.apache.maven.api.services.Lookup; import org.apache.maven.execution.BuildResumptionAnalyzer; @@ -78,8 +77,7 @@ void testSessionScopeIsExitedOnWorkspaceReaderError() throws Exception { mock(BuildResumptionDataRepository.class), null, mock(DefaultSessionFactory.class), - null, - List.of()); + null); MavenExecutionResult result = defaultMaven.execute(request); diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8766SpiWorkspaceReaderTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8766SpiWorkspaceReaderTest.java index 347c012c8cc7..f51336c2ee22 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8766SpiWorkspaceReaderTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8766SpiWorkspaceReaderTest.java @@ -58,6 +58,11 @@ void testSpiWorkspaceReaderFilteredFromPluginResolution() throws Exception { // The SPI workspace reader should be called for artifact resolution in the main session // (e.g., during project dependency resolution, model building, etc.) List logLines = verifier.loadLogLines(); + boolean hasFindArtifactCalls = + logLines.stream().anyMatch(line -> line.contains("[SPI-WR] findArtifact(")); + assertTrue( + hasFindArtifactCalls, + "SPI workspace reader should be consulted during regular artifact resolution"); // Verify it was NOT called for plugin resolution // When isApplicableForPluginResolution() returns false, the reader is removed from From 44e96719ff15c9445307ef859c40dcfb353c7b79 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Thu, 10 Sep 2026 12:04:25 +0000 Subject: [PATCH 3/4] [MNG-8766] Replace isApplicableForPluginResolution() with PluginRealmCache.invalidate(Artifact) Instead of filtering workspace readers out of plugin resolution, expose a proper invalidate(Artifact) SPI on PluginRealmCache so IDEs can purge stale plugin realms when a workspace artifact is rebuilt. This approach is more accurate: IDEs trust their own build, so if a plugin has been rebuilt in the workspace, it should be used. The invalidate() method lets the IDE evict the cached realm on demand, rather than blanket-excluding workspace readers from plugin resolution. Changes: - PluginRealmCache: add default invalidate(org.apache.maven.api.Artifact) - DefaultPluginRealmCache: implement invalidate() by evicting matching entries (matched on groupId:artifactId:version) and disposing their ClassRealms - WorkspaceReader SPI: remove isApplicableForPluginResolution() - SpiWorkspaceReaderAdapter: remove isApplicableForPluginResolution() - DefaultPluginDependenciesResolver: remove filterWorkspaceReadersForPluginResolution() - IT: simplify test to verify SPI discovery and artifact resolution consultation --- .../apache/maven/api/spi/WorkspaceReader.java | 16 -------- .../maven/plugin/DefaultPluginRealmCache.java | 19 ++++++++++ .../apache/maven/plugin/PluginRealmCache.java | 20 ++++++++++ .../DefaultPluginDependenciesResolver.java | 28 -------------- .../resolver/SpiWorkspaceReaderAdapter.java | 9 ----- .../MavenITmng8766SpiWorkspaceReaderTest.java | 38 ++++++++----------- .../extensions/TestSpiWorkspaceReader.java | 5 --- 7 files changed, 55 insertions(+), 80 deletions(-) diff --git a/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/WorkspaceReader.java b/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/WorkspaceReader.java index 517f5a330379..e08f96e8cdca 100644 --- a/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/WorkspaceReader.java +++ b/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/WorkspaceReader.java @@ -62,20 +62,4 @@ public interface WorkspaceReader extends SpiService { * @return list of available versions, may be empty */ List findVersions(Artifact artifact); - - /** - * Whether this workspace reader should participate in plugin resolution. - * - *

IDE workspace readers should return {@code false} here: plugin realms are cached - * by {@code DefaultPluginRealmCache} (which is {@code @Singleton}) and cannot be purged - * within a session, so resolving plugins from the workspace can lead to stale classloaders - * when workspace sources change. - * - *

Defaults to {@code true} (participates in plugin resolution). - * - * @return {@code true} if this reader should be consulted during plugin resolution - */ - default boolean isApplicableForPluginResolution() { - return true; - } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultPluginRealmCache.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultPluginRealmCache.java index 1e822a2ccb0c..cf3407022718 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultPluginRealmCache.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/DefaultPluginRealmCache.java @@ -200,6 +200,25 @@ public void flush() { cache.clear(); } + @Override + public void invalidate(org.apache.maven.api.Artifact artifact) { + cache.entrySet().removeIf(entry -> { + boolean matches = entry.getValue().getArtifacts().stream() + .anyMatch(a -> a.getGroupId().equals(artifact.getGroupId()) + && a.getArtifactId().equals(artifact.getArtifactId()) + && a.getVersion().equals(artifact.getVersion().toString())); + if (matches) { + ClassRealm realm = entry.getValue().getRealm(); + try { + realm.getWorld().disposeRealm(realm.getId()); + } catch (NoSuchRealmException e) { + // ignore + } + } + return matches; + }); + } + protected static int pluginHashCode(Plugin plugin) { return CacheUtils.pluginHashCode(plugin); } diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/PluginRealmCache.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/PluginRealmCache.java index 7fcbd87949a6..9fc68b145d21 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/PluginRealmCache.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/PluginRealmCache.java @@ -94,6 +94,26 @@ default CacheRecord get(Key key, PluginRealmSupplier supplier) void flush(); + /** + * Invalidates all cache entries whose resolved artifacts include the given artifact. + * + *

IDE integrators and other workspace-aware tools can call this method when a workspace + * artifact changes on disk, so that subsequent builds will re-resolve the affected plugin + * realms from the updated sources rather than using a stale cached classloader. + * + *

Implementations may choose to match on {@code groupId:artifactId:version} only, ignoring + * classifier and extension, to maximize the chance of invalidating related entries. + * + *

The default implementation is a no-op (safe for existing implementations that do not + * track artifact-to-entry mappings). + * + * @param artifact the workspace artifact that has changed, never {@code null} + * @since 4.1.0 + */ + default void invalidate(org.apache.maven.api.Artifact artifact) { + // no-op by default + } + /** * Registers the specified cache record for usage with the given project. Integrators can use the information * collected from this method in combination with a custom cache implementation to dispose unused records from the diff --git a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultPluginDependenciesResolver.java b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultPluginDependenciesResolver.java index 2e587e1bad4c..a17bbbd0fc04 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultPluginDependenciesResolver.java +++ b/impl/maven-core/src/main/java/org/apache/maven/plugin/internal/DefaultPluginDependenciesResolver.java @@ -36,8 +36,6 @@ import org.apache.maven.model.Dependency; import org.apache.maven.model.Plugin; import org.apache.maven.plugin.PluginResolutionException; -import org.apache.maven.resolver.MavenChainedWorkspaceReader; -import org.apache.maven.resolver.SpiWorkspaceReaderAdapter; import org.eclipse.aether.DefaultRepositorySystemSession; import org.eclipse.aether.RepositorySystem; import org.eclipse.aether.RepositorySystemSession; @@ -50,7 +48,6 @@ import org.eclipse.aether.graph.DependencyFilter; import org.eclipse.aether.graph.DependencyNode; import org.eclipse.aether.repository.RemoteRepository; -import org.eclipse.aether.repository.WorkspaceReader; import org.eclipse.aether.resolution.ArtifactDescriptorException; import org.eclipse.aether.resolution.ArtifactDescriptorRequest; import org.eclipse.aether.resolution.ArtifactDescriptorResult; @@ -112,9 +109,6 @@ public Artifact resolve(Plugin plugin, List repositories, Repo DefaultRepositorySystemSession pluginSession = new DefaultRepositorySystemSession(session); pluginSession.setArtifactDescriptorPolicy(new SimpleArtifactDescriptorPolicy(true, false)); - // Filter out SPI workspace readers that opt out of plugin resolution - filterWorkspaceReadersForPluginResolution(session, pluginSession); - ArtifactDescriptorRequest request = new ArtifactDescriptorRequest(pluginArtifact, repositories, REPOSITORY_CONTEXT); request.setTrace(trace); @@ -273,9 +267,6 @@ private DependencyResult resolveInternal( pluginSession.setDependencySelector(session.getDependencySelector()); pluginSession.setDependencyGraphTransformer(session.getDependencyGraphTransformer()); - // Filter out SPI workspace readers that opt out of plugin resolution - filterWorkspaceReadersForPluginResolution(session, pluginSession); - CollectRequest request = new CollectRequest(); request.setRequestContext(REPOSITORY_CONTEXT); request.setRepositories(repositories); @@ -316,23 +307,4 @@ private DependencyResult resolveInternal( RequestTraceHelper.exit(trace); } } - - /** - * Filters workspace readers in the plugin session, removing SPI workspace readers - * that have opted out of plugin resolution via - * {@link org.apache.maven.api.spi.WorkspaceReader#isApplicableForPluginResolution()}. - */ - private void filterWorkspaceReadersForPluginResolution( - RepositorySystemSession session, DefaultRepositorySystemSession pluginSession) { - WorkspaceReader workspaceReader = session.getWorkspaceReader(); - if (workspaceReader instanceof MavenChainedWorkspaceReader chainedReader) { - List filtered = chainedReader.getReaders().stream() - .filter(r -> !(r instanceof SpiWorkspaceReaderAdapter adapter) - || adapter.isApplicableForPluginResolution()) - .collect(Collectors.toList()); - if (filtered.size() != chainedReader.getReaders().size()) { - pluginSession.setWorkspaceReader(MavenChainedWorkspaceReader.of(filtered)); - } - } - } } diff --git a/impl/maven-core/src/main/java/org/apache/maven/resolver/SpiWorkspaceReaderAdapter.java b/impl/maven-core/src/main/java/org/apache/maven/resolver/SpiWorkspaceReaderAdapter.java index d0f8309d8871..74ccf6b04aee 100644 --- a/impl/maven-core/src/main/java/org/apache/maven/resolver/SpiWorkspaceReaderAdapter.java +++ b/impl/maven-core/src/main/java/org/apache/maven/resolver/SpiWorkspaceReaderAdapter.java @@ -67,15 +67,6 @@ public List findVersions(Artifact artifact) { return delegate.findVersions(toApiArtifact(artifact)); } - /** - * Whether the underlying SPI reader should participate in plugin resolution. - * - * @return {@code true} if applicable for plugin resolution - */ - public boolean isApplicableForPluginResolution() { - return delegate.isApplicableForPluginResolution(); - } - /** * Returns the underlying SPI workspace reader. */ diff --git a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8766SpiWorkspaceReaderTest.java b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8766SpiWorkspaceReaderTest.java index f51336c2ee22..6b5739259ab5 100644 --- a/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8766SpiWorkspaceReaderTest.java +++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8766SpiWorkspaceReaderTest.java @@ -23,21 +23,27 @@ import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Integration test for the {@code WorkspaceReader} SPI in maven-api-spi. + * Integration test for the {@code WorkspaceReader} SPI in maven-api-spi (MNG-8766). * - *

Verifies that SPI workspace readers with {@code isApplicableForPluginResolution() == false} - * are discovered and active, but are not consulted during plugin resolution. + *

Verifies that SPI workspace readers are: + *

    + *
  1. Discovered via DI and active during the build session
  2. + *
  3. Consulted for regular artifact resolution (dependency resolution, model building, etc.)
  4. + *
+ * + *

The {@code PluginRealmCache.invalidate(Artifact)} SPI allows IDE integrators to purge + * stale plugin realms when workspace artifacts are rebuilt, rather than opting out of plugin + * resolution entirely. * * @since 4.1.0 */ class MavenITmng8766SpiWorkspaceReaderTest extends AbstractMavenIntegrationTestCase { @Test - void testSpiWorkspaceReaderFilteredFromPluginResolution() throws Exception { + void testSpiWorkspaceReaderDiscoveredAndConsulted() throws Exception { Path testDir = extractResources("mng-8766-spi-workspace-reader"); // First, install the extension @@ -46,34 +52,22 @@ void testSpiWorkspaceReaderFilteredFromPluginResolution() throws Exception { verifier.execute(); verifier.verifyErrorFreeLog(); - // Run the project that uses the extension — process-resources triggers plugin resolution + // Run the project that uses the extension — process-resources triggers artifact resolution verifier = newVerifier(testDir.resolve("project")); verifier.addCliArgument("process-resources"); verifier.execute(); verifier.verifyErrorFreeLog(); - // Verify the SPI workspace reader was created (proves discovery works) + // Verify the SPI workspace reader was created (proves DI discovery works) verifier.verifyTextInLog("[SPI-WR] created"); - // The SPI workspace reader should be called for artifact resolution in the main session - // (e.g., during project dependency resolution, model building, etc.) + // Verify the SPI workspace reader was consulted during artifact resolution + // (findArtifact is called for dependencies, model resolution, etc.) List logLines = verifier.loadLogLines(); boolean hasFindArtifactCalls = logLines.stream().anyMatch(line -> line.contains("[SPI-WR] findArtifact(")); assertTrue( hasFindArtifactCalls, - "SPI workspace reader should be consulted during regular artifact resolution"); - - // Verify it was NOT called for plugin resolution - // When isApplicableForPluginResolution() returns false, the reader is removed from - // the plugin session's workspace reader chain, so it should not see any findArtifact - // calls for plugins like maven-resources-plugin - boolean hasPluginCalls = logLines.stream() - .anyMatch(line -> - line.contains("[SPI-WR] findArtifact(") && line.contains("maven-resources-plugin")); - assertFalse( - hasPluginCalls, - "SPI workspace reader with isApplicableForPluginResolution()=false " - + "should NOT be called for plugin resolution"); + "SPI workspace reader should be consulted during artifact resolution"); } } diff --git a/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/src/main/java/org/apache/maven/its/extensions/TestSpiWorkspaceReader.java b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/src/main/java/org/apache/maven/its/extensions/TestSpiWorkspaceReader.java index 11022bb61c31..db766070f182 100644 --- a/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/src/main/java/org/apache/maven/its/extensions/TestSpiWorkspaceReader.java +++ b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/src/main/java/org/apache/maven/its/extensions/TestSpiWorkspaceReader.java @@ -49,9 +49,4 @@ public List findVersions(Artifact artifact) { log.info("[SPI-WR] findVersions({})", artifact.key()); return Collections.emptyList(); } - - @Override - public boolean isApplicableForPluginResolution() { - return false; - } } From 7b734aae0abb121aa53590acfccdedcd80548288 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Thu, 10 Sep 2026 12:12:56 +0000 Subject: [PATCH 4/4] fix: update stale description elements after PluginRealmCache rework --- .../resources/mng-8766-spi-workspace-reader/extension/pom.xml | 2 +- .../resources/mng-8766-spi-workspace-reader/project/pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/pom.xml b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/pom.xml index 2038996cd6e8..310e24ae9303 100644 --- a/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/pom.xml +++ b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/extension/pom.xml @@ -25,7 +25,7 @@ under the License. jar Maven Integration Test :: spi-workspace-reader - SPI WorkspaceReader extension that opts out of plugin resolution + SPI WorkspaceReader extension for IDE workspace artifact resolution diff --git a/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/pom.xml b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/pom.xml index 797799b4df65..7fd6dabec155 100644 --- a/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/pom.xml +++ b/its/core-it-suite/src/test/resources/mng-8766-spi-workspace-reader/project/pom.xml @@ -25,7 +25,7 @@ under the License. jar Maven Integration Test :: mng-8766 - Verify that SPI WorkspaceReader is used for dependency resolution but not for plugin resolution. + Verify that SPI WorkspaceReader is discovered and consulted for artifact resolution.