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..e08f96e8cdca
--- /dev/null
+++ b/api/maven-api-spi/src/main/java/org/apache/maven/api/spi/WorkspaceReader.java
@@ -0,0 +1,65 @@
+/*
+ * 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);
+}
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..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
@@ -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;
@@ -213,6 +214,14 @@ private MavenExecutionResult doExecute(MavenExecutionRequest request) {
try {
MavenChainedWorkspaceReader chainedWorkspaceReader =
new MavenChainedWorkspaceReader(request.getWorkspaceReader(), ideWorkspaceReader);
+ // 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)) {
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/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/resolver/SpiWorkspaceReaderAdapter.java b/impl/maven-core/src/main/java/org/apache/maven/resolver/SpiWorkspaceReaderAdapter.java
new file mode 100644
index 000000000000..74ccf6b04aee
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/resolver/SpiWorkspaceReaderAdapter.java
@@ -0,0 +1,188 @@
+/*
+ * 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));
+ }
+
+ /**
+ * 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/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..6b5739259ab5
--- /dev/null
+++ b/its/core-it-suite/src/test/java/org/apache/maven/it/MavenITmng8766SpiWorkspaceReaderTest.java
@@ -0,0 +1,73 @@
+/*
+ * 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.assertTrue;
+
+/**
+ * Integration test for the {@code WorkspaceReader} SPI in maven-api-spi (MNG-8766).
+ *
+ * Verifies that SPI workspace readers are:
+ *
+ * - Discovered via DI and active during the build session
+ * - Consulted for regular artifact resolution (dependency resolution, model building, etc.)
+ *
+ *
+ * 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 testSpiWorkspaceReaderDiscoveredAndConsulted() 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 artifact resolution
+ verifier = newVerifier(testDir.resolve("project"));
+ verifier.addCliArgument("process-resources");
+ verifier.execute();
+ verifier.verifyErrorFreeLog();
+
+ // Verify the SPI workspace reader was created (proves DI discovery works)
+ verifier.verifyTextInLog("[SPI-WR] created");
+
+ // 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 artifact 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..310e24ae9303
--- /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 for IDE workspace artifact 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..db766070f182
--- /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,52 @@
+/*
+ * 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();
+ }
+}
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..7fd6dabec155
--- /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 discovered and consulted for artifact resolution.
+
+
+
+ junit
+ junit
+ 4.13.2
+ test
+
+
+
+