You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by su...@apache.org on 2026/09/26 11:58:35 UTC

(groovy) 02/02: GROOVY-12411: Fold STC into groovy-lsp core and drop the public SPI

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

asf-gitbox-commits pushed a commit to branch GROOVY-12411
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit 85293321e62c3d5cc9465bd700fac23ee8032ac5
Author: Daniel Sun <su...@apache.org>
AuthorDate: Sat Sep 26 20:56:43 2026 +0900

    GROOVY-12411: Fold STC into groovy-lsp core and drop the public SPI
    
    The only in-tree consumer of GroovyLspExtension was the static type
    checking pass. Run that after SEMANTIC_ANALYSIS as core behaviour.
    Client settings go through workspace/didChangeConfiguration and an
    asynchronous workspace/configuration pull.
---
 subprojects/groovy-lsp/AGENTS.md                   |  15 +-
 subprojects/groovy-lsp/ARCHITECTURE.md             |  72 +++---
 .../apache/groovy/lsp/GroovyLanguageServer.java    |   4 +-
 .../apache/groovy/lsp/internal/ExtensionHost.java  | 219 ------------------
 .../groovy/lsp/internal/LanguageServerContext.java |  52 ++---
 .../lsp/internal/compile/CompilerSettings.java     |  73 +++++-
 .../lsp/internal/compile/GroovyCompiler.java       |  44 +---
 .../groovy/lsp/internal/feature/TypeInference.java |   2 +-
 ...gExtension.java => StaticTypeCheckingPass.java} |  38 ++--
 .../lsp/internal/protocol/ClientFeatures.java      |   5 +
 .../protocol/GroovyTextDocumentService.java        |   3 +-
 .../internal/protocol/GroovyWorkspaceService.java  |  97 ++++----
 .../internal/protocol/ServerCapabilityFactory.java |  29 +--
 .../apache/groovy/lsp/spi/GroovyLspExtension.java  | 184 ---------------
 .../apache/groovy/lsp/spi/GroovyLspSession.java    | 112 ---------
 .../org/apache/groovy/lsp/spi/package-info.java    |  33 ---
 .../org.apache.groovy.lsp.spi.GroovyLspExtension   |  19 --
 .../groovy-lsp/src/spec/doc/groovy-lsp.adoc        |  50 ++---
 .../groovy/lsp/internal/HelperCoverageTest.groovy  | 147 ++++++------
 .../apache/groovy/lsp/internal/LspFixture.groovy   |  27 +++
 .../intelligence/StaticTypeCheckingPassTest.groovy | 232 +++++++++++++++++++
 .../internal/protocol/ProtocolCoverageTest.groovy  |  15 +-
 .../lsp/internal/spi/GroovyLspExtensionTest.groovy | 250 ---------------------
 23 files changed, 588 insertions(+), 1134 deletions(-)

diff --git a/subprojects/groovy-lsp/AGENTS.md b/subprojects/groovy-lsp/AGENTS.md
index 30b51d7317..a9316b13df 100644
--- a/subprojects/groovy-lsp/AGENTS.md
+++ b/subprojects/groovy-lsp/AGENTS.md
@@ -30,16 +30,11 @@ Rename of a method *call* is allowed only when `methodTarget` is
 set. Do not add an identifier-text fallback (that is how
 `groovy-language-server-edt`'s AST path rewrites the wrong `foo`).
 
-New Groovy-specific behaviour that is not core language support
-belongs in a `GroovyLspExtension`, not a fork of
-`LanguageServerContext`. Do not add OSGi, extra JSON-RPC methods, or
-a second hover/completion SPI — `configure` / `initialized` /
-`afterCompile` / extra diagnostics (including `ModuleNode`) / extra
-code actions / commands are the extension valves. Inject members in
-`afterCompile` so core hover and completion see them. The SPI must
-not take `internal` types; plugins see `GroovyLspSession` (document
-text, folders, classpath, extra settings, apply-edit) and groovy-core
-AST (`CompilationUnit`, `ModuleNode`).
+New Groovy-specific behaviour that is not protocol adaptation belongs
+in `feature/*` or `compile/*`, not a public SPI. Configuration is
+`workspace/didChangeConfiguration` and `workspace/configuration`
+under the `groovy` section (see `groovy-lsp.adoc`). Do not add OSGi,
+extra JSON-RPC methods, or a second hover/completion SPI.
 
 Do not add language-server fields or visitor special cases to
 groovy-core (`AnnotatedNode` name spans, `AstQuery` parameter walks).
diff --git a/subprojects/groovy-lsp/ARCHITECTURE.md b/subprojects/groovy-lsp/ARCHITECTURE.md
index 2e18402ea4..bbdcedc7b8 100644
--- a/subprojects/groovy-lsp/ARCHITECTURE.md
+++ b/subprojects/groovy-lsp/ARCHITECTURE.md
@@ -44,22 +44,21 @@ A **Groovy language core** that editors and AI tools can share:
   conservative rename on `.groovy` sources.
 - AI agents get the same snapshot: structure, types where the compiler
   knows them, and edits that refuse to guess. A later `groovy-mcp`
-  module is expected to wrap this server so a model can ask “what is
-  this identifier?” without re-implementing the compiler.
+  module is expected to wrap the same in-process engine so a model can
+  ask “what is this identifier?” without re-implementing the compiler.
 
 It is intended for **direct use by an IDE or editor as an LSP
-server**, as the **semantic backend** for Groovy-aware AI, and as the
-**Groovy-AST compile + protocol loop** other Apache-2.0 Groovy LSPs
-can sit on. Plugins implement incubating
-[`GroovyLspExtension`](src/main/java/org/apache/groovy/lsp/spi/GroovyLspExtension.java)
-(`ServiceLoader` or tests register an `ExtensionHost`). That is how
-Jenkins GDSL, a forced STC pass, or `groovy-mcp` add behaviour
-without forking `LanguageServerContext`.
-
-It is not a complete IDE. Eclipse JDT / groovy-eclipse servers
-cannot replace this compiler through the SPI — they remain a
-parallel stack. GDSL and Maven downloads stay in the *plugin*, not
-in this module.
+server**, as the **semantic backend** for Groovy-aware AI, and as an
+**in-process language-services library**. `feature/*` and `compile/*`
+answer questions about sources; `protocol/*` is the LSP adapter.
+Console, groovysh, a notebook kernel, or an MCP process can pull
+answers from that library. There is no public extension SPI in this
+first cut.
+
+It is not a complete IDE. Eclipse JDT / groovy-eclipse remain a
+parallel stack. GDSL, Maven downloads, and a forced-STC toggle are
+out of scope here; the last of those would be a configuration key,
+not an extension.
 
 ### What it is not
 
@@ -186,30 +185,27 @@ call. Wrong edits are worse for an agent than a refused rename.
   line. Compile reports `window/workDoneProgress` when the client
   supports it. References skip files that do not contain the
   identifier as a word.
-- **Compile cache.** A fingerprint of open buffers, extra-file mtimes,
-  settings and extension ids skips a full recompile when nothing
-  changed. Compile runs off the session lock so document sync is
-  not blocked; a generation counter drops superseded compiles. When the
-  client omits classpath and source paths, `WorkspaceLayout` uses
-  `src/main/groovy` (and friends) plus `lib/*.jar` — it never invokes
-  Gradle or Maven.
-- **Extensions.** `GroovyLspExtension` may `configure` the
-  `CompilerConfiguration` (with a `GroovyLspSession` so extra
-  `groovy.*` settings and folders are visible), run `initialized` /
-  `shutdown`, run `afterCompile` on the live `CompilationUnit`
-  (before the snapshot freezes), add diagnostics (URI + text +
-  optional `ModuleNode`) and code actions (with source text), and
-  handle extra commands through `GroovyLspSession`. The session is
-  apply-edit plus document text, folders, classpath, source paths,
-  extra settings and position encoding — not a compile handle. A
-  throwing plugin is isolated (`id()` / `commands()` included); it
-  must not compile or publish on its own. Hover and completion stay
-  in core: inject AST in `afterCompile`. The server ships a default
-  `groovy.lsp.stc` extension that runs `StaticTypeCheckingVisitor` on
-  `@TypeChecked` / `@CompileStatic` members only and copies
-  `DIRECT_METHOD_CALL_TARGET` onto `methodTarget`. `TypeInference`
-  also reads `StaticTypesMarker.INFERRED_TYPE` when a plugin wrote
-  it.
+- **Compile cache.** A fingerprint of open buffers, extra-file mtimes
+  and settings skips a full recompile when nothing changed. Compile
+  runs off the session lock so document sync is not blocked; a
+  generation counter drops superseded compiles. When the client omits
+  classpath and source paths, `WorkspaceLayout` uses `src/main/groovy`
+  (and friends) plus `lib/*.jar` — it never invokes Gradle or Maven.
+- **Static type checking.** After `SEMANTIC_ANALYSIS`, a core
+  `StaticTypeCheckingPass` runs `StaticTypeCheckingVisitor` on
+  `@TypeChecked` / `@CompileStatic` members only (not
+  `TypeCheckingMode.SKIP`) and copies `DIRECT_METHOD_CALL_TARGET`
+  onto `methodTarget`. Dynamic Groovy is left alone. `TypeInference`
+  also reads `StaticTypesMarker.INFERRED_TYPE` when that pass wrote
+  it. A throwing class is isolated so the snapshot still publishes.
+- **Configuration.** `workspace/didChangeConfiguration` applies the
+  `groovy` map (or the root map). After `initialized`, if the client
+  advertised `workspace.configuration`, the server also pulls section
+  `groovy` via `workspace/configuration` without blocking the JSON-RPC
+  reader. Keys: `classpath`,
+  `sourcePaths`, `grapeEnabled`, `astTestEnabled`, plus opaque extra
+  `groovy.*` strings that force a recompile and rebuild the class
+  loader. There is no ServiceLoader order.
 - **Semantic tokens.** The advertised legend is append-only. Lexer
   tokens (`GroovySourceTokens`) fill `keyword` / `string` / `number` /
   `comment` even when the AST is missing. Declarations and use-site
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/GroovyLanguageServer.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/GroovyLanguageServer.java
index 7af63e49db..2f20a32289 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/GroovyLanguageServer.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/GroovyLanguageServer.java
@@ -90,8 +90,7 @@ public final class GroovyLanguageServer implements LanguageServer, LanguageClien
         } else if (params.getRootUri() != null) {
             context.addWorkspaceFolder(Uris.parse(params.getRootUri()));
         }
-        InitializeResult result = new InitializeResult(capabilities.create(
-                context.getPositionEncoding(), context.getExtensions().commands()));
+        InitializeResult result = new InitializeResult(capabilities.create(context.getPositionEncoding()));
         result.setServerInfo(new ServerInfo("groovy-lsp", GroovyLanguageServer.class.getPackage() == null
                 ? "dev" : String.valueOf(GroovyLanguageServer.class.getPackage().getImplementationVersion())));
         return CompletableFuture.completedFuture(result);
@@ -100,6 +99,7 @@ public final class GroovyLanguageServer implements LanguageServer, LanguageClien
     @Override
     public void initialized(final InitializedParams params) {
         context.setInitialized(true);
+        workspace.pullConfiguration();
         context.scheduleRecompile(0);
     }
 
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/ExtensionHost.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/ExtensionHost.java
deleted file mode 100644
index cf829331c3..0000000000
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/ExtensionHost.java
+++ /dev/null
@@ -1,219 +0,0 @@
-/*
- *  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.groovy.lsp.internal;
-
-import org.apache.groovy.lsp.internal.compile.CompiledDocument;
-import org.apache.groovy.lsp.internal.workspace.TextDocument;
-import org.apache.groovy.lsp.spi.GroovyLspExtension;
-import org.apache.groovy.lsp.spi.GroovyLspSession;
-import org.codehaus.groovy.control.CompilationUnit;
-import org.codehaus.groovy.control.CompilerConfiguration;
-import org.eclipse.lsp4j.CodeAction;
-import org.eclipse.lsp4j.Command;
-import org.eclipse.lsp4j.Diagnostic;
-import org.eclipse.lsp4j.Range;
-import org.eclipse.lsp4j.jsonrpc.messages.Either;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
-import java.util.ServiceLoader;
-import java.util.function.Consumer;
-
-/**
- * Discovers and fans out {@link GroovyLspExtension} instances. One bad
- * plugin must not abort compile or a request.
- */
-public final class ExtensionHost {
-
-    private static final ExtensionHost NONE = new ExtensionHost(List.of());
-
-    private final List<GroovyLspExtension> extensions;
-
-    private ExtensionHost(final List<GroovyLspExtension> extensions) {
-        this.extensions = List.copyOf(extensions);
-    }
-
-    /**
-     * @return a host with no plugins
-     */
-    public static ExtensionHost none() {
-        return NONE;
-    }
-
-    /**
-     * @param extensions explicit plugins, typically from tests
-     * @return a host
-     */
-    public static ExtensionHost of(final GroovyLspExtension... extensions) {
-        if (extensions == null || extensions.length == 0) {
-            return NONE;
-        }
-        List<GroovyLspExtension> list = new ArrayList<>();
-        for (GroovyLspExtension extension : extensions) {
-            if (extension != null) {
-                list.add(extension);
-            }
-        }
-        return list.isEmpty() ? NONE : new ExtensionHost(list);
-    }
-
-    /**
-     * Loads {@link ServiceLoader} registrations from {@code loader}.
-     *
-     * @param loader class loader, or {@code null} for the SPI type's loader
-     * @return a host
-     */
-    public static ExtensionHost discover(final ClassLoader loader) {
-        ClassLoader use = loader == null ? GroovyLspExtension.class.getClassLoader() : loader;
-        List<GroovyLspExtension> found = new ArrayList<>();
-        for (GroovyLspExtension extension : ServiceLoader.load(GroovyLspExtension.class, use)) {
-            found.add(extension);
-        }
-        return found.isEmpty() ? NONE : new ExtensionHost(found);
-    }
-
-    /**
-     * @return plugin ids in registration order
-     */
-    public List<String> ids() {
-        List<String> ids = new ArrayList<>(extensions.size());
-        for (GroovyLspExtension extension : extensions) {
-            try {
-                String id = extension.id();
-                ids.add(id == null || id.isBlank() ? extension.getClass().getName() : id);
-            } catch (RuntimeException ignored) {
-                ids.add(extension.getClass().getName());
-            }
-        }
-        return ids;
-    }
-
-    /**
-     * @return advertised extra execute-command names
-     */
-    public List<String> commands() {
-        List<String> commands = new ArrayList<>();
-        for (GroovyLspExtension extension : extensions) {
-            try {
-                List<String> extra = extension.commands();
-                if (extra != null) {
-                    commands.addAll(extra);
-                }
-            } catch (RuntimeException ignored) {
-                // one plugin must not abort initialize
-            }
-        }
-        return commands;
-    }
-
-    /**
-     * @return the plugins
-     */
-    public List<GroovyLspExtension> all() {
-        return extensions;
-    }
-
-    public void configure(final CompilerConfiguration configuration) {
-        configure(configuration, (GroovyLspSession) null);
-    }
-
-    public void configure(final CompilerConfiguration configuration, final GroovyLspSession session) {
-        each(extension -> extension.configure(configuration, session));
-    }
-
-    public void initialized(final GroovyLspSession session) {
-        each(extension -> extension.initialized(session));
-    }
-
-    public void shutdown() {
-        each(GroovyLspExtension::shutdown);
-    }
-
-    public void afterCompile(final CompilationUnit unit) {
-        each(extension -> extension.afterCompile(unit));
-    }
-
-    public List<Diagnostic> extraDiagnostics(final CompiledDocument document) {
-        List<Diagnostic> extras = new ArrayList<>();
-        each(extension -> {
-            List<Diagnostic> extra = document == null
-                    ? null
-                    : extension.extraDiagnostics(document.getUri(), document.getText(), document.getModule());
-            if (extra != null) {
-                extras.addAll(extra);
-            }
-        });
-        return extras;
-    }
-
-    public List<Either<Command, CodeAction>> extraCodeActions(final TextDocument document,
-                                                              final List<Diagnostic> diagnostics,
-                                                              final Range range) {
-        List<Either<Command, CodeAction>> extras = new ArrayList<>();
-        each(extension -> {
-            List<Either<Command, CodeAction>> extra = document == null
-                    ? null
-                    : extension.extraCodeActions(document.getUri(), document.getText(), diagnostics, range);
-            if (extra != null) {
-                extras.addAll(extra);
-            }
-        });
-        return extras;
-    }
-
-    private void each(final Consumer<GroovyLspExtension> action) {
-        for (GroovyLspExtension extension : extensions) {
-            try {
-                action.accept(extension);
-            } catch (RuntimeException ignored) {
-                // one plugin must not abort the host
-            }
-        }
-    }
-
-    public Object executeCommand(final String command, final List<Object> arguments,
-                                 final GroovyLspSession session) {
-        if (command == null) {
-            return null;
-        }
-        for (GroovyLspExtension extension : extensions) {
-            try {
-                List<String> commands = extension.commands();
-                if (commands == null || !commands.contains(command)) {
-                    continue;
-                }
-                return extension.executeCommand(command, arguments, session);
-            } catch (RuntimeException ignored) {
-                return null;
-            }
-        }
-        return null;
-    }
-
-    @Override
-    public boolean equals(final Object other) {
-        return other instanceof ExtensionHost host && ids().equals(host.ids());
-    }
-
-    @Override
-    public int hashCode() {
-        return Objects.hash(ids());
-    }
-}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/LanguageServerContext.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/LanguageServerContext.java
index a20961f509..21c3ad5553 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/LanguageServerContext.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/LanguageServerContext.java
@@ -38,7 +38,7 @@ import org.apache.groovy.lsp.internal.protocol.ClientFeatures;
 import org.apache.groovy.lsp.internal.util.Uris;
 import org.apache.groovy.lsp.internal.workspace.DocumentStore;
 import org.apache.groovy.lsp.internal.workspace.TextDocument;
-import org.apache.groovy.lsp.spi.GroovyLspSession;
+
 import org.eclipse.lsp4j.ApplyWorkspaceEditParams;
 import org.eclipse.lsp4j.WorkspaceEdit;
 import org.eclipse.lsp4j.ClientCapabilities;
@@ -70,9 +70,8 @@ import java.util.concurrent.atomic.AtomicReference;
 /**
  * Mutable session state for one language-server process.
  */
-public final class LanguageServerContext implements GroovyLspSession {
+public final class LanguageServerContext {
 
-    private final ExtensionHost extensions;
     private final DocumentStore documents = new DocumentStore();
     private final GroovyCompiler compiler = new GroovyCompiler();
     private final WorkspaceScanner scanner = new WorkspaceScanner();
@@ -106,21 +105,9 @@ public final class LanguageServerContext implements GroovyLspSession {
     private String compileFingerprint;
 
     /**
-     * Creates a session and loads {@code GroovyLspExtension} registrations
-     * from this class's loader.
+     * Creates a session.
      */
     public LanguageServerContext() {
-        this(ExtensionHost.discover(LanguageServerContext.class.getClassLoader()));
-    }
-
-    /**
-     * Creates a session with an explicit plugin host. Tests use this to
-     * register a fake extension without {@code META-INF/services}.
-     *
-     * @param extensions plugin host, or {@code null} for none
-     */
-    public LanguageServerContext(final ExtensionHost extensions) {
-        this.extensions = extensions == null ? ExtensionHost.none() : extensions;
     }
 
     /**
@@ -150,7 +137,7 @@ public final class LanguageServerContext implements GroovyLspSession {
                 effective = WorkspaceLayout.withInferred(settings.get(), folders);
                 extra = scanner.scan(folders, effective.getSourcePaths());
                 open = documents.snapshot();
-                fingerprint = fingerprint(open, extra, effective) + '|' + extensions.ids();
+                fingerprint = fingerprint(open, extra, effective);
                 if (fingerprint.equals(compileFingerprint) && snapshot.get() != CompilationSnapshot.EMPTY) {
                     // Another compile already committed this snapshot but may
                     // not have published yet. Fall through to publishDiagnostics.
@@ -166,7 +153,7 @@ public final class LanguageServerContext implements GroovyLspSession {
                 beginProgress(token, "Compiling Groovy");
                 CompilationSnapshot compiled;
                 try {
-                    compiled = compiler.compile(open, extra, effective, getClass().getClassLoader(), extensions, this);
+                    compiled = compiler.compile(open, extra, effective, getClass().getClassLoader());
                 } finally {
                     endProgress(token);
                 }
@@ -212,7 +199,7 @@ public final class LanguageServerContext implements GroovyLspSession {
     }
 
     /**
-     * Compiler, unused-import and plugin diagnostics for one document.
+     * Compiler and unused-import diagnostics for one document.
      * Used by both push and pull diagnostic paths.
      *
      * @param document compiled document
@@ -225,11 +212,9 @@ public final class LanguageServerContext implements GroovyLspSession {
         PositionEncoding encoding = positionEncoding.get();
         List<Diagnostic> converted = new ArrayList<>(diagnostics.convert(document, encoding));
         converted.addAll(ImportSupport.unusedDiagnostics(document, encoding));
-        converted.addAll(extensions.extraDiagnostics(document));
         return converted;
     }
 
-    @Override
     public void applyEdit(final WorkspaceEdit edit) {
         LanguageClient languageClient = client.get();
         if (languageClient == null || edit == null
@@ -239,7 +224,6 @@ public final class LanguageServerContext implements GroovyLspSession {
         languageClient.applyEdit(new ApplyWorkspaceEditParams(edit));
     }
 
-    @Override
     public String documentText(final URI uri) {
         if (uri == null) {
             return null;
@@ -252,29 +236,34 @@ public final class LanguageServerContext implements GroovyLspSession {
         return compiled == null ? null : compiled.getText();
     }
 
-    @Override
     public List<URI> workspaceFolders() {
         synchronized (workspaceFolders) {
             return List.copyOf(workspaceFolders);
         }
     }
 
-    @Override
     public List<String> classpath() {
         return settings.get().getClasspath();
     }
 
-    @Override
     public List<String> sourcePaths() {
         return settings.get().getSourcePaths();
     }
 
-    @Override
     public Map<String, String> extraSettings() {
         return settings.get().getExtra();
     }
 
-    @Override
+    /**
+     * One extra {@code groovy.*} setting from workspace configuration.
+     *
+     * @param key setting name without the {@code groovy.} prefix
+     * @return the value, or {@code null}
+     */
+    public String extraSetting(final String key) {
+        return key == null ? null : extraSettings().get(key);
+    }
+
     public String positionEncoding() {
         return positionEncoding.get().protocolName();
     }
@@ -320,7 +309,6 @@ public final class LanguageServerContext implements GroovyLspSession {
         debounce.shutdownNow();
         compiler.close();
         documents.clear();
-        extensions.shutdown();
     }
 
     public DocumentStore getDocuments() {
@@ -383,10 +371,6 @@ public final class LanguageServerContext implements GroovyLspSession {
         return hierarchy;
     }
 
-    public ExtensionHost getExtensions() {
-        return extensions;
-    }
-
     public LanguageClient getClient() {
         return client.get();
     }
@@ -433,11 +417,7 @@ public final class LanguageServerContext implements GroovyLspSession {
     }
 
     public void setInitialized(final boolean initialized) {
-        boolean was = this.initialized;
         this.initialized = initialized;
-        if (initialized && !was) {
-            extensions.initialized(this);
-        }
     }
 
     public boolean isShutdown() {
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompilerSettings.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompilerSettings.java
index 252d1b2c60..fbac3f2faa 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompilerSettings.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/CompilerSettings.java
@@ -27,6 +27,7 @@ import java.net.URL;
 import java.net.URLClassLoader;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -41,6 +42,13 @@ import java.util.Set;
 public record CompilerSettings(List<String> classpath, List<String> sourcePaths, int throughPhase,
                                boolean grapeEnabled, boolean astTestEnabled, Map<String, String> extra) {
 
+    private static final String CLASSPATH = "classpath";
+    private static final String SOURCE_PATHS = "sourcePaths";
+    private static final String GRAPE_ENABLED = "grapeEnabled";
+    private static final String AST_TEST_ENABLED = "astTestEnabled";
+    private static final Set<String> CLIENT_KEYS = Set.of(
+            CLASSPATH, SOURCE_PATHS, GRAPE_ENABLED, AST_TEST_ENABLED);
+
     /**
      * Creates settings with the supplied classpath and source paths.
      *
@@ -58,7 +66,7 @@ public record CompilerSettings(List<String> classpath, List<String> sourcePaths,
     }
 
     /**
-     * Settings without extra plugin keys.
+     * Settings without extra {@code groovy.*} keys.
      *
      * @param classpath jar and directory entries
      * @param sourcePaths extra Groovy source roots
@@ -105,6 +113,69 @@ public record CompilerSettings(List<String> classpath, List<String> sourcePaths,
         return extra;
     }
 
+    /**
+     * Merges a client {@code groovy} settings map into {@code current}.
+     * Missing keys keep the current value. An empty {@code classpath} or
+     * {@code sourcePaths} list also keeps the current list, matching
+     * {@code workspace/didChangeConfiguration}.
+     *
+     * @param map client map, possibly {@code null}
+     * @param current settings to merge into, possibly {@code null}
+     * @return merged settings, never {@code null}
+     */
+    public static CompilerSettings fromClientMap(final Map<?, ?> map, final CompilerSettings current) {
+        CompilerSettings base = current == null ? defaults() : current;
+        if (map == null || map.isEmpty()) {
+            return base;
+        }
+        List<String> classpath = stringList(map.get(CLASSPATH));
+        List<String> sourcePaths = stringList(map.get(SOURCE_PATHS));
+        boolean grape = map.containsKey(GRAPE_ENABLED)
+                ? Boolean.TRUE.equals(map.get(GRAPE_ENABLED)) : base.isGrapeEnabled();
+        boolean astTest = map.containsKey(AST_TEST_ENABLED)
+                ? Boolean.TRUE.equals(map.get(AST_TEST_ENABLED)) : base.isAstTestEnabled();
+        return new CompilerSettings(
+                classpath.isEmpty() ? base.getClasspath() : classpath,
+                sourcePaths.isEmpty() ? base.getSourcePaths() : sourcePaths,
+                base.getThroughPhase(), grape, astTest, extraFrom(map, base));
+    }
+
+    private static Map<String, String> extraFrom(final Map<?, ?> map, final CompilerSettings current) {
+        Map<String, String> extra = new LinkedHashMap<>(current.getExtra());
+        for (Map.Entry<?, ?> entry : map.entrySet()) {
+            if (entry.getKey() != null) {
+                String key = entry.getKey().toString();
+                if (!CLIENT_KEYS.contains(key)) {
+                    extra.put(key, extraValue(entry.getValue()));
+                }
+            }
+        }
+        return extra;
+    }
+
+    private static String extraValue(final Object value) {
+        if (value == null) {
+            return "";
+        }
+        if (value instanceof List<?> list) {
+            return stringList(list).toString();
+        }
+        return value.toString();
+    }
+
+    private static List<String> stringList(final Object value) {
+        if (!(value instanceof List<?> list)) {
+            return List.of();
+        }
+        List<String> result = new ArrayList<>();
+        for (Object item : list) {
+            if (item != null) {
+                result.add(item.toString());
+            }
+        }
+        return result;
+    }
+
     /**
      * Builds a {@link CompilerConfiguration} for a tooling compile.
      *
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/GroovyCompiler.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/GroovyCompiler.java
index 5a2bc5686b..3fe5c9e662 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/GroovyCompiler.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/compile/GroovyCompiler.java
@@ -19,10 +19,9 @@
 package org.apache.groovy.lsp.internal.compile;
 
 import groovy.lang.GroovyClassLoader;
-import org.apache.groovy.lsp.internal.ExtensionHost;
+import org.apache.groovy.lsp.internal.intelligence.StaticTypeCheckingPass;
 import org.apache.groovy.lsp.internal.util.Uris;
 import org.apache.groovy.lsp.internal.workspace.TextDocument;
-import org.apache.groovy.lsp.spi.GroovyLspSession;
 import org.codehaus.groovy.control.CompilationFailedException;
 import org.codehaus.groovy.control.CompilationUnit;
 import org.codehaus.groovy.control.CompilerConfiguration;
@@ -73,47 +72,12 @@ public final class GroovyCompiler implements AutoCloseable {
      */
     public CompilationSnapshot compile(final Collection<TextDocument> openDocuments, final Collection<Path> extraFiles,
                                        final CompilerSettings settings, final ClassLoader parentLoader) {
-        return compile(openDocuments, extraFiles, settings, parentLoader, ExtensionHost.none());
-    }
-
-    /**
-     * Compiles like {@link #compile(Collection, Collection, CompilerSettings, ClassLoader)}
-     * and then runs {@code extensions} so plugins can inject AST or run an
-     * intelligence pass before the snapshot is frozen.
-     *
-     * @param openDocuments open editor buffers
-     * @param extraFiles additional Groovy files
-     * @param settings compiler settings
-     * @param parentLoader parent class loader
-     * @param extensions plugins, never {@code null}
-     * @return an immutable snapshot
-     */
-    public CompilationSnapshot compile(final Collection<TextDocument> openDocuments, final Collection<Path> extraFiles,
-                                       final CompilerSettings settings, final ClassLoader parentLoader,
-                                       final ExtensionHost extensions) {
-        return compile(openDocuments, extraFiles, settings, parentLoader, extensions, null);
-    }
-
-    /**
-     * Compiles like
-     * {@link #compile(Collection, Collection, CompilerSettings, ClassLoader, ExtensionHost)}
-     * and passes {@code session} to {@code configure} so plugins can read
-     * folders and extra settings.
-     *
-     * @param session session, possibly {@code null}
-     * @return an immutable snapshot
-     */
-    public CompilationSnapshot compile(final Collection<TextDocument> openDocuments, final Collection<Path> extraFiles,
-                                       final CompilerSettings settings, final ClassLoader parentLoader,
-                                       final ExtensionHost extensions, final GroovyLspSession session) {
         CompilerConfiguration configuration = settings.toConfiguration();
-        ExtensionHost host = extensions == null ? ExtensionHost.none() : extensions;
-        host.configure(configuration, session);
         String previousAstTest = System.getProperty(ASTTEST_ENABLE);
         String previousGrape = System.getProperty(GRAPE_ENABLE);
         applyProcessFlags(settings);
         try {
-            return compileUnit(openDocuments, extraFiles, settings, parentLoader, host, configuration);
+            return compileUnit(openDocuments, extraFiles, settings, parentLoader, configuration);
         } finally {
             restoreProperty(ASTTEST_ENABLE, previousAstTest);
             restoreProperty(GRAPE_ENABLE, previousGrape);
@@ -122,7 +86,7 @@ public final class GroovyCompiler implements AutoCloseable {
 
     private CompilationSnapshot compileUnit(final Collection<TextDocument> openDocuments, final Collection<Path> extraFiles,
                                             final CompilerSettings settings, final ClassLoader parentLoader,
-                                            final ExtensionHost host, final CompilerConfiguration configuration) {
+                                            final CompilerConfiguration configuration) {
         Map<URI, Integer> versions = new LinkedHashMap<>();
         Map<URI, String> texts = new LinkedHashMap<>();
         Map<URI, String> javaOverlays = new LinkedHashMap<>();
@@ -140,7 +104,7 @@ public final class GroovyCompiler implements AutoCloseable {
         } catch (CompilationFailedException ignored) {
             // keep the partial AST and collected messages
         }
-        host.afterCompile(unit);
+        StaticTypeCheckingPass.apply(unit);
 
         Map<URI, CompiledDocument> compiled = CompilationSnapshot.newMap();
         var sourceIterator = unit.iterator();
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/TypeInference.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/TypeInference.java
index a6b0f3bcbe..35c4e91227 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/TypeInference.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/feature/TypeInference.java
@@ -33,7 +33,7 @@ import org.codehaus.groovy.transform.stc.StaticTypesMarker;
 /**
  * Best-effort types for hover and inlay when the AST still says
  * {@code Object} / dynamic. Prefers {@link StaticTypesMarker} metadata
- * written by an {@code afterCompile} STC pass, then an initializer
+ * written by the core static type-checking pass, then an initializer
  * expression. Does not run the static type checker itself.
  */
 final class TypeInference {
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingExtension.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingPass.java
similarity index 80%
rename from subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingExtension.java
rename to subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingPass.java
index c5e4be183a..5bc4f0ce7d 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingExtension.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingPass.java
@@ -21,7 +21,6 @@ package org.apache.groovy.lsp.internal.intelligence;
 import groovy.transform.CompileStatic;
 import groovy.transform.TypeChecked;
 import org.apache.groovy.lsp.internal.compile.AstQuery;
-import org.apache.groovy.lsp.spi.GroovyLspExtension;
 import org.codehaus.groovy.ast.AnnotatedNode;
 import org.codehaus.groovy.ast.ClassHelper;
 import org.codehaus.groovy.ast.ClassNode;
@@ -38,24 +37,28 @@ import java.util.LinkedHashSet;
 import java.util.Set;
 
 /**
- * Default intelligence pass for {@code @TypeChecked} / {@code @CompileStatic}
- * members. Those transforms run at {@code INSTRUCTION_SELECTION}, which
- * groovy-lsp never reaches; this copies {@code DIRECT_METHOD_CALL_TARGET}
- * onto {@code methodTarget} so hover, rename and parameter-name inlays
- * see a compiler bind. Dynamic Groovy is left alone.
+ * Core post-pass after {@code SEMANTIC_ANALYSIS}. {@code @TypeChecked} /
+ * {@code @CompileStatic} transforms run at {@code INSTRUCTION_SELECTION},
+ * which groovy-lsp never reaches; this copies
+ * {@code DIRECT_METHOD_CALL_TARGET} onto {@code methodTarget} so hover,
+ * rename and parameter-name inlays see a compile-time bind. Dynamic
+ * Groovy is left alone.
  */
-public final class StaticTypeCheckingExtension implements GroovyLspExtension {
+public final class StaticTypeCheckingPass {
 
     private static final ClassNode TYPE_CHECKED = ClassHelper.make(TypeChecked.class);
     private static final ClassNode COMPILE_STATIC = ClassHelper.make(CompileStatic.class);
 
-    @Override
-    public String id() {
-        return "groovy.lsp.stc";
+    private StaticTypeCheckingPass() {
     }
 
-    @Override
-    public void afterCompile(final CompilationUnit unit) {
+    /**
+     * Runs the pass on every module in {@code unit}. Failures are isolated
+     * so a bad class does not drop the snapshot.
+     *
+     * @param unit the compiled unit, possibly {@code null}
+     */
+    public static void apply(final CompilationUnit unit) {
         if (unit == null) {
             return;
         }
@@ -67,7 +70,11 @@ public final class StaticTypeCheckingExtension implements GroovyLspExtension {
                 continue;
             }
             for (ClassNode classNode : module.getClasses()) {
-                checkClass(source, unit, classNode);
+                try {
+                    checkClass(source, unit, classNode);
+                } catch (RuntimeException ignored) {
+                    // keep the rest of the snapshot
+                }
             }
         }
     }
@@ -98,7 +105,10 @@ public final class StaticTypeCheckingExtension implements GroovyLspExtension {
         bindTargets(classNode);
     }
 
-    private static void bindTargets(final ClassNode classNode) {
+    static void bindTargets(final ClassNode classNode) {
+        if (classNode == null) {
+            return;
+        }
         AstQuery.walk(classNode, (node, ctx) -> {
             if (node instanceof MethodCallExpression call && call.getMethodTarget() == null) {
                 Object target = call.getNodeMetaData(StaticTypesMarker.DIRECT_METHOD_CALL_TARGET);
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ClientFeatures.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ClientFeatures.java
index f342911b05..8a83f350dc 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ClientFeatures.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ClientFeatures.java
@@ -66,6 +66,11 @@ public final class ClientFeatures {
         return workspace != null && Boolean.TRUE.equals(workspace.getApplyEdit());
     }
 
+    public static boolean workspaceConfiguration(final ClientCapabilities capabilities) {
+        WorkspaceClientCapabilities workspace = capabilities == null ? null : capabilities.getWorkspace();
+        return workspace != null && Boolean.TRUE.equals(workspace.getConfiguration());
+    }
+
     public static boolean workDoneProgress(final ClientCapabilities capabilities) {
         return capabilities != null && capabilities.getWindow() != null
                 && Boolean.TRUE.equals(capabilities.getWindow().getWorkDoneProgress());
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyTextDocumentService.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyTextDocumentService.java
index 2c35463625..0240c979e8 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyTextDocumentService.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyTextDocumentService.java
@@ -239,8 +239,7 @@ public final class GroovyTextDocumentService implements TextDocumentService {
             List<Either<Command, CodeAction>> actions = new ArrayList<>(
                     context.getSupport().codeActions(document, context.getSnapshot(),
                             diagnostics, params.getRange(), context.getPositionEncoding(),
-                            context.getWorkspaceFolders(), context.getSettings().getSourcePaths()));
-            actions.addAll(context.getExtensions().extraCodeActions(document, diagnostics, params.getRange()));
+                            context.workspaceFolders(), context.getSettings().getSourcePaths()));
             return filterByOnly(actions, params.getContext() == null ? null : params.getContext().getOnly());
         });
     }
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyWorkspaceService.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyWorkspaceService.java
index d4245de9d6..441799ea3c 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyWorkspaceService.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/GroovyWorkspaceService.java
@@ -24,6 +24,8 @@ import org.apache.groovy.lsp.internal.compile.CompilerSettings;
 import org.apache.groovy.lsp.internal.util.Uris;
 import org.apache.groovy.lsp.internal.workspace.TextDocument;
 import org.eclipse.lsp4j.ApplyWorkspaceEditParams;
+import org.eclipse.lsp4j.ConfigurationItem;
+import org.eclipse.lsp4j.ConfigurationParams;
 import org.eclipse.lsp4j.Position;
 import org.eclipse.lsp4j.DidChangeConfigurationParams;
 import org.eclipse.lsp4j.DidChangeWatchedFilesParams;
@@ -38,14 +40,13 @@ import org.eclipse.lsp4j.WorkspaceFolder;
 import org.eclipse.lsp4j.WorkspaceSymbol;
 import org.eclipse.lsp4j.WorkspaceSymbolParams;
 import org.eclipse.lsp4j.jsonrpc.messages.Either;
+import org.eclipse.lsp4j.services.LanguageClient;
 import org.eclipse.lsp4j.services.WorkspaceService;
 
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
 import java.util.function.BiFunction;
 
 /**
@@ -56,10 +57,8 @@ public final class GroovyWorkspaceService implements WorkspaceService {
     static final String ORGANIZE_IMPORTS = "groovy.lsp.organizeImports";
     static final String SHOW_REFERENCES = "groovy.lsp.showReferences";
     static final String GOTO_SUPER_METHOD = "groovy.lsp.gotoSuperMethod";
-    private static final String GRAPE_ENABLED = "grapeEnabled";
-    private static final String AST_TEST_ENABLED = "astTestEnabled";
-    private static final Set<String> CORE_SETTING_KEYS = Set.of(
-            "classpath", "sourcePaths", GRAPE_ENABLED, AST_TEST_ENABLED);
+    private static final String GROOVY_SECTION = "groovy";
+    private static final long CONFIGURATION_TIMEOUT_SECONDS = 2L;
 
     private final LanguageServerContext context;
 
@@ -71,7 +70,7 @@ public final class GroovyWorkspaceService implements WorkspaceService {
     public void didChangeConfiguration(final DidChangeConfigurationParams params) {
         Object settings = params.getSettings();
         if (settings instanceof Map<?, ?> map) {
-            Object groovy = map.get("groovy");
+            Object groovy = map.get(GROOVY_SECTION);
             if (groovy instanceof Map<?, ?> groovyMap) {
                 apply(groovyMap);
             } else {
@@ -81,6 +80,36 @@ public final class GroovyWorkspaceService implements WorkspaceService {
         context.scheduleRecompile(0);
     }
 
+    /**
+     * Requests section {@code groovy} via {@code workspace/configuration}
+     * when the client advertised that capability. Applied asynchronously
+     * so the JSON-RPC reader is not blocked waiting for the reply.
+     */
+    public void pullConfiguration() {
+        LanguageClient client = context.getClient();
+        if (client == null || !ClientFeatures.workspaceConfiguration(context.getClientCapabilities())) {
+            return;
+        }
+        ConfigurationItem item = new ConfigurationItem();
+        item.setSection(GROOVY_SECTION);
+        CompletableFuture<List<Object>> future;
+        try {
+            future = client.configuration(new ConfigurationParams(List.of(item)));
+        } catch (RuntimeException ignored) {
+            return;
+        }
+        if (future == null) {
+            return;
+        }
+        future.orTimeout(CONFIGURATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+                .whenComplete((values, error) -> {
+                    if (error == null) {
+                        applyPulled(values);
+                        context.scheduleRecompile(0);
+                    }
+                });
+    }
+
     @Override
     public void didChangeWatchedFiles(final DidChangeWatchedFilesParams params) {
         context.scheduleRecompile(100);
@@ -120,7 +149,7 @@ public final class GroovyWorkspaceService implements WorkspaceService {
         if (core != null) {
             return core;
         }
-        return CompletableFuture.completedFuture(context.getExtensions().executeCommand(command, args, context));
+        return CompletableFuture.completedFuture(null);
     }
 
     private CompletableFuture<Object> coreCommand(final String command, final List<Object> args) {
@@ -160,42 +189,18 @@ public final class GroovyWorkspaceService implements WorkspaceService {
         return CompletableFuture.completedFuture(action.apply(document, new Position(line, character)));
     }
 
-    @SuppressWarnings("unchecked")
     private void apply(final Map<?, ?> map) {
-        List<String> classpath = stringList(map.get("classpath"));
-        List<String> sourcePaths = stringList(map.get("sourcePaths"));
-        CompilerSettings current = context.getSettings();
-        boolean grape = map.containsKey(GRAPE_ENABLED)
-                ? Boolean.TRUE.equals(map.get(GRAPE_ENABLED)) : current.isGrapeEnabled();
-        boolean astTest = map.containsKey(AST_TEST_ENABLED)
-                ? Boolean.TRUE.equals(map.get(AST_TEST_ENABLED)) : current.isAstTestEnabled();
-        context.setSettings(new CompilerSettings(
-                classpath.isEmpty() ? current.getClasspath() : classpath,
-                sourcePaths.isEmpty() ? current.getSourcePaths() : sourcePaths,
-                current.getThroughPhase(), grape, astTest, extraSettings(map, current)));
+        context.setSettings(CompilerSettings.fromClientMap(map, context.getSettings()));
     }
 
-    private static Map<String, String> extraSettings(final Map<?, ?> map, final CompilerSettings current) {
-        Map<String, String> extra = new LinkedHashMap<>(current.getExtra());
-        for (Map.Entry<?, ?> entry : map.entrySet()) {
-            if (entry.getKey() != null) {
-                String key = entry.getKey().toString();
-                if (!CORE_SETTING_KEYS.contains(key)) {
-                    extra.put(key, extraValue(entry.getValue()));
-                }
-            }
-        }
-        return extra;
-    }
-
-    private static String extraValue(final Object value) {
-        if (value == null) {
-            return "";
+    private void applyPulled(final List<Object> values) {
+        if (values == null || values.isEmpty()) {
+            return;
         }
-        if (value instanceof List<?> list) {
-            return stringList(list).toString();
+        Object first = values.get(0);
+        if (first instanceof Map<?, ?> map) {
+            apply(map);
         }
-        return value.toString();
     }
 
     private void applyFolders(final List<WorkspaceFolder> folders, final boolean add) {
@@ -214,16 +219,4 @@ public final class GroovyWorkspaceService implements WorkspaceService {
         }
     }
 
-    private static List<String> stringList(final Object value) {
-        if (!(value instanceof List<?> list)) {
-            return List.of();
-        }
-        List<String> result = new ArrayList<>();
-        for (Object item : list) {
-            if (item != null) {
-                result.add(item.toString());
-            }
-        }
-        return result;
-    }
 }
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ServerCapabilityFactory.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ServerCapabilityFactory.java
index 22ebb86524..e8ed67f899 100644
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ServerCapabilityFactory.java
+++ b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/internal/protocol/ServerCapabilityFactory.java
@@ -44,7 +44,6 @@ import org.eclipse.lsp4j.TypeHierarchyRegistrationOptions;
 import org.eclipse.lsp4j.WorkspaceFoldersOptions;
 import org.eclipse.lsp4j.WorkspaceServerCapabilities;
 
-import java.util.ArrayList;
 import java.util.List;
 
 /**
@@ -61,18 +60,6 @@ public final class ServerCapabilityFactory {
      * @return capabilities
      */
     public ServerCapabilities create(final PositionEncoding encoding) {
-        return create(encoding, List.of());
-    }
-
-    /**
-     * Builds server capabilities including extra execute-command names
-     * contributed by plugins.
-     *
-     * @param encoding negotiated position encoding
-     * @param extraCommands plugin command ids
-     * @return capabilities
-     */
-    public ServerCapabilities create(final PositionEncoding encoding, final List<String> extraCommands) {
         ServerCapabilities capabilities = new ServerCapabilities();
         capabilities.setPositionEncoding(encoding.protocolName());
         capabilities.setTextDocumentSync(TextDocumentSyncKind.Incremental);
@@ -133,18 +120,10 @@ public final class ServerCapabilityFactory {
         workspace.setFileOperations(fileOps);
         capabilities.setWorkspace(workspace);
 
-        List<String> commands = new ArrayList<>();
-        commands.add(GroovyWorkspaceService.ORGANIZE_IMPORTS);
-        commands.add(GroovyWorkspaceService.SHOW_REFERENCES);
-        commands.add(GroovyWorkspaceService.GOTO_SUPER_METHOD);
-        if (extraCommands != null) {
-            for (String command : extraCommands) {
-                if (command != null && !command.isBlank() && !commands.contains(command)) {
-                    commands.add(command);
-                }
-            }
-        }
-        capabilities.setExecuteCommandProvider(new ExecuteCommandOptions(commands));
+        capabilities.setExecuteCommandProvider(new ExecuteCommandOptions(List.of(
+                GroovyWorkspaceService.ORGANIZE_IMPORTS,
+                GroovyWorkspaceService.SHOW_REFERENCES,
+                GroovyWorkspaceService.GOTO_SUPER_METHOD)));
         return capabilities;
     }
 }
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/GroovyLspExtension.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/GroovyLspExtension.java
deleted file mode 100644
index eaf88eb8c4..0000000000
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/GroovyLspExtension.java
+++ /dev/null
@@ -1,184 +0,0 @@
-/*
- *  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.groovy.lsp.spi;
-
-import org.apache.groovy.lang.annotation.Incubating;
-import org.codehaus.groovy.ast.ModuleNode;
-import org.codehaus.groovy.control.CompilationUnit;
-import org.codehaus.groovy.control.CompilerConfiguration;
-import org.eclipse.lsp4j.CodeAction;
-import org.eclipse.lsp4j.Command;
-import org.eclipse.lsp4j.Diagnostic;
-import org.eclipse.lsp4j.Range;
-import org.eclipse.lsp4j.jsonrpc.messages.Either;
-
-import java.net.URI;
-import java.util.List;
-
-/**
- * One contribution to the language server. Implementors ship on the
- * server classpath and register via {@code ServiceLoader},
- * or tests pass them to {@code ExtensionHost.of}. Every method has a
- * no-op default so a GDSL, STC, or MCP plugin implements only what it
- * needs.
- * <p>
- * Hooks run on the compile thread except {@code extraCodeActions} and
- * {@code executeCommand}, which run on the request thread and must
- * read {@link GroovyLspSession} rather than compiling. Hover and
- * completion stay in core: inject members in {@code afterCompile} so
- * the snapshot sees them. There is no second hover/completion SPI.
- */
-@Incubating
-public interface GroovyLspExtension {
-
-    /**
-     * Stable id used in compile fingerprints so enabling a plugin
-     * forces a recompile.
-     *
-     * @return a non-blank id
-     */
-    default String id() {
-        return getClass().getName();
-    }
-
-    /**
-     * Mutates {@code configuration} before the compilation unit is
-     * created (extra customizers, extra disabled transforms).
-     *
-     * @param configuration compiler configuration
-     */
-    default void configure(final CompilerConfiguration configuration) {
-    }
-
-    /**
-     * Like {@link #configure(CompilerConfiguration)} with a session so a
-     * plugin can read workspace folders and extra {@code groovy.*}
-     * settings (for example {@code groovy.dependencies}) before mutating
-     * the classpath.
-     *
-     * @param configuration compiler configuration
-     * @param session session, possibly {@code null} in tests
-     */
-    default void configure(final CompilerConfiguration configuration, final GroovyLspSession session) {
-        configure(configuration);
-    }
-
-    /**
-     * Called once after {@code initialized}. Load catalogs that need
-     * workspace folders here.
-     *
-     * @param session session
-     */
-    default void initialized(final GroovyLspSession session) {
-    }
-
-    /**
-     * Called when the session shuts down. Release plugin resources.
-     */
-    default void shutdown() {
-    }
-
-    /**
-     * Runs after {@code unit.compile} and before the snapshot is built.
-     * GDSL injection and a static-type-checking intelligence pass belong
-     * here. Must not generate class files.
-     *
-     * @param unit the compiled unit, possibly partial
-     */
-    default void afterCompile(final CompilationUnit unit) {
-    }
-
-    /**
-     * Extra diagnostics published with the compiler's own messages.
-     *
-     * @param uri document URI
-     * @param sourceText document text
-     * @return extra diagnostics, never {@code null}
-     */
-    default List<Diagnostic> extraDiagnostics(final URI uri, final String sourceText) {
-        return List.of();
-    }
-
-    /**
-     * Extra diagnostics with the compiled module when compile produced
-     * one. {@code module} is groovy-core AST, not a groovy-lsp internal
-     * type. Default delegates to
-     * {@link #extraDiagnostics(URI, String)}.
-     *
-     * @param uri document URI
-     * @param sourceText document text
-     * @param module compiled module, possibly {@code null}
-     * @return extra diagnostics, never {@code null}
-     */
-    default List<Diagnostic> extraDiagnostics(final URI uri, final String sourceText, final ModuleNode module) {
-        return extraDiagnostics(uri, sourceText);
-    }
-
-    /**
-     * Extra code actions for a range.
-     *
-     * @param uri document URI
-     * @param diagnostics client diagnostics
-     * @param range requested range
-     * @return extra actions, never {@code null}
-     */
-    default List<Either<Command, CodeAction>> extraCodeActions(final URI uri, final List<Diagnostic> diagnostics,
-                                                               final Range range) {
-        return List.of();
-    }
-
-    /**
-     * Extra code actions with source text so a plugin can build edits
-     * without a second document store. Default delegates to
-     * {@link #extraCodeActions(URI, List, Range)}.
-     *
-     * @param uri document URI
-     * @param sourceText document text, possibly {@code null}
-     * @param diagnostics client diagnostics
-     * @param range requested range
-     * @return extra actions, never {@code null}
-     */
-    default List<Either<Command, CodeAction>> extraCodeActions(final URI uri, final String sourceText,
-                                                               final List<Diagnostic> diagnostics,
-                                                               final Range range) {
-        return extraCodeActions(uri, diagnostics, range);
-    }
-
-    /**
-     * Extra {@code workspace/executeCommand} names to advertise.
-     *
-     * @return command identifiers, never {@code null}
-     */
-    default List<String> commands() {
-        return List.of();
-    }
-
-    /**
-     * Handles {@code command} when {@link #commands()} contains it.
-     *
-     * @param command command id
-     * @param arguments protocol arguments
-     * @param session session (edits, document text, folders, extra settings)
-     * @return the LSP result, possibly {@code null}
-     */
-    default Object executeCommand(final String command, final List<Object> arguments,
-                                  final GroovyLspSession session) {
-        return null;
-    }
-}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/GroovyLspSession.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/GroovyLspSession.java
deleted file mode 100644
index 17f405d27d..0000000000
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/GroovyLspSession.java
+++ /dev/null
@@ -1,112 +0,0 @@
-/*
- *  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.groovy.lsp.spi;
-
-import org.apache.groovy.lang.annotation.Incubating;
-import org.eclipse.lsp4j.WorkspaceEdit;
-
-import java.net.URI;
-import java.util.List;
-import java.util.Map;
-
-/**
- * Narrow session view for {@link GroovyLspExtension}. Plugins must not
- * compile or publish diagnostics through this type. Use
- * {@link GroovyLspExtension#afterCompile} to see the live AST.
- */
-@Incubating
-public interface GroovyLspSession {
-
-    /**
-     * Asks the client to apply {@code edit} when {@code workspace/applyEdit}
-     * is available. No-op if there is no client or the client did not
-     * advertise the capability.
-     *
-     * @param edit a workspace edit
-     */
-    void applyEdit(WorkspaceEdit edit);
-
-    /**
-     * Open-buffer or last-compiled text for {@code uri}.
-     *
-     * @param uri document URI
-     * @return the text, or {@code null} when unknown
-     */
-    default String documentText(URI uri) {
-        return null;
-    }
-
-    /**
-     * Workspace folders in registration order.
-     *
-     * @return folders, never {@code null}
-     */
-    default List<URI> workspaceFolders() {
-        return List.of();
-    }
-
-    /**
-     * Client {@code groovy.classpath} entries.
-     *
-     * @return classpath strings, never {@code null}
-     */
-    default List<String> classpath() {
-        return List.of();
-    }
-
-    /**
-     * Client {@code groovy.sourcePaths} entries.
-     *
-     * @return source paths, never {@code null}
-     */
-    default List<String> sourcePaths() {
-        return List.of();
-    }
-
-    /**
-     * Other {@code groovy.*} settings the core does not interpret
-     * ({@code groovy.dependencies}, a GDSL path, …). Values are the
-     * client's string form.
-     *
-     * @return extra settings, never {@code null}
-     */
-    default Map<String, String> extraSettings() {
-        return Map.of();
-    }
-
-    /**
-     * One extra setting.
-     *
-     * @param key setting name without the {@code groovy.} prefix
-     * @return the value, or {@code null}
-     */
-    default String extraSetting(final String key) {
-        return key == null ? null : extraSettings().get(key);
-    }
-
-    /**
-     * Negotiated LSP position encoding ({@code utf-16}, {@code utf-8},
-     * or {@code utf-32}).
-     *
-     * @return the protocol name, never {@code null}
-     */
-    default String positionEncoding() {
-        return "utf-16";
-    }
-}
diff --git a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/package-info.java b/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/package-info.java
deleted file mode 100644
index be9810e060..0000000000
--- a/subprojects/groovy-lsp/src/main/java/org/apache/groovy/lsp/spi/package-info.java
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- *  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.
- */
-/**
- * Incubating SPI so other Apache-2.0 Groovy language servers and
- * {@code groovy-mcp} can sit on groovy-lsp's compile and protocol loop
- * without forking it. A {@link GroovyLspExtension} is discovered with
- * {@code ServiceLoader} or registered in tests. Inject AST in
- * {@code afterCompile}; read folders and extra settings from
- * {@link GroovyLspSession}. There is no second hover/completion SPI.
- * <p>
- * This is not a slot for replacing the Groovy compiler with Eclipse JDT.
- * Joint Java/Groovy servers remain a parallel stack.
- */
-@Incubating
-package org.apache.groovy.lsp.spi;
-
-import org.apache.groovy.lang.annotation.Incubating;
diff --git a/subprojects/groovy-lsp/src/main/resources/META-INF/services/org.apache.groovy.lsp.spi.GroovyLspExtension b/subprojects/groovy-lsp/src/main/resources/META-INF/services/org.apache.groovy.lsp.spi.GroovyLspExtension
deleted file mode 100644
index 6f717bd16a..0000000000
--- a/subprojects/groovy-lsp/src/main/resources/META-INF/services/org.apache.groovy.lsp.spi.GroovyLspExtension
+++ /dev/null
@@ -1,19 +0,0 @@
-#
-#  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.
-#
-org.apache.groovy.lsp.internal.intelligence.StaticTypeCheckingExtension
diff --git a/subprojects/groovy-lsp/src/spec/doc/groovy-lsp.adoc b/subprojects/groovy-lsp/src/spec/doc/groovy-lsp.adoc
index e8524699d0..1a79db8bb0 100644
--- a/subprojects/groovy-lsp/src/spec/doc/groovy-lsp.adoc
+++ b/subprojects/groovy-lsp/src/spec/doc/groovy-lsp.adoc
@@ -53,7 +53,7 @@ The server advertises and implements:
 * Incremental text synchronization
 * Diagnostics (push and pull). Compiler messages plus unused-import
   hints. `@TypeChecked` / `@CompileStatic` members also receive a
-  default static-type-checking pass (see <<default-extension>>)
+  core static-type-checking pass (see <<static-type-checking>>)
 * Completion (keywords, snippets, members, workspace types with
   auto-import, GDK when a prefix is typed) and `completionItem/resolve`
 * Hover, signature help (overloads). Unique-arity hover on an unbound
@@ -126,17 +126,20 @@ document color, notebooks, inline completion, inline values, monikers,
 
 == Configuration
 
-Client settings under `groovy` (or the root of `workspace/didChangeConfiguration`):
+Client settings under `groovy` (or the root of
+`workspace/didChangeConfiguration`). After `initialized`, if the
+client advertised `workspace.configuration`, the server also pulls
+section `groovy` via `workspace/configuration` (asynchronously, so
+the JSON-RPC reader is not blocked).
 
 * `classpath` — extra jars and directories
 * `sourcePaths` — extra source roots relative to workspace folders
 * `grapeEnabled` — allow `@Grab` during compile (default `false`)
 * `astTestEnabled` — allow `@ASTTest` during compile (default `false`)
-* any other `groovy.*` key — stored as a string and passed to plugins
-  via `GroovyLspSession.extraSettings()` (for example
-  `groovy.dependencies`). The core does not download or interpret them.
-  Changing these keys forces a recompile and rebuilds the class loader
-  so a plugin that appends jars in `configure` is visible.
+* any other `groovy.*` key — stored as a string (for example
+  `groovy.dependencies`). The core does not download or interpret
+  them. Changing these keys forces a recompile and rebuilds the
+  class loader.
 
 == Classpath
 
@@ -168,20 +171,12 @@ AI tools are expected to use the same process, and a later
 `groovy-mcp` module is expected to wrap it so a model can query
 Groovy structure and types without embedding the compiler.
 
-It is meant to be used **directly by an IDE or editor**, and as a
-library other Apache-2.0 Groovy language servers can embed. Plugins
-implement `org.apache.groovy.lsp.spi.GroovyLspExtension` and register
-with `ServiceLoader` (tests use `ExtensionHost.of`). That is the seam
-for Jenkins GDSL, a forced STC pass, extra commands, and a later
-`groovy-mcp` without forking the stdio loop.
-
-A plugin that injects members in `afterCompile` is visible to core
-hover, completion, navigation and rename. There is no second
-hover/completion SPI. `GroovyLspSession` exposes document text,
-workspace folders, classpath, source paths, extra `groovy.*` settings
-(for example `groovy.dependencies`) and the negotiated position
-encoding. Extra diagnostics may see the compiled `ModuleNode`.
-Eclipse JDT / groovy-eclipse servers remain a parallel stack.
+It is meant to be used **directly by an IDE or editor**, and as an
+in-process language-services library (`feature/*` + `compile/*`) with
+the LSP server as the first client (`protocol/*`). A later
+`groovy-mcp` module is expected to wrap that same engine. There is no
+public extension SPI in this first cut. Eclipse JDT / groovy-eclipse
+servers remain a parallel stack. GDSL is not implemented here.
 
 It is not a complete workspace/IDE: it does not invoke Gradle or Maven,
 does not load GDSL itself, and does not debug or run tests. When the
@@ -218,11 +213,11 @@ Call-site parameter-name inlays use the same unique-arity helper as
 hover. They are display chrome: a later `groovy-mcp` wrapper must not
 treat them as facts, and they never insert named-argument text.
 
-== Default static-type-checking extension
+== Static type checking
+[[static-type-checking]]
 [[default-extension]]
 
-The server ships one built-in `GroovyLspExtension`
-(`groovy.lsp.stc`). After `SEMANTIC_ANALYSIS` it runs
+After `SEMANTIC_ANALYSIS` a core pass runs
 `StaticTypeCheckingVisitor` on classes and methods annotated
 `@TypeChecked` or `@CompileStatic` (not `@CompileDynamic` /
 `TypeCheckingMode.SKIP`) and copies `DIRECT_METHOD_CALL_TARGET` onto
@@ -232,13 +227,12 @@ rename of those calls, and unlabeled parameter-name inlays.
 It does **not** force STC on dynamic Groovy, does not raise the compile
 phase, and does not generate class files. STC errors for annotated
 members land on the source unit's error collector and are published as
-ordinary diagnostics. Additional plugins still register with
-`ServiceLoader`.
+ordinary diagnostics.
 
 == Honesty for AI tools
 
-Diagnostics are compiler messages, unused-import hints, and plugin
-extras. Hover `_inferred_` and inlay tooltip `inferred` mean the
+Diagnostics are compiler messages and unused-import hints. Hover
+`_inferred_` and inlay tooltip `inferred` mean the
 server guessed from unique arity or an initializer — not a compiler
 bind. Rename of an unbound call is refused. Models should treat
 inferred chrome as a hint, not as source to copy.
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/HelperCoverageTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/HelperCoverageTest.groovy
index ce04b4c95f..6b2577c2a3 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/HelperCoverageTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/HelperCoverageTest.groovy
@@ -22,7 +22,6 @@ import org.apache.groovy.lsp.GroovyLanguageServer
 import org.apache.groovy.lsp.GroovyLanguageServerLauncher
 import org.apache.groovy.lsp.internal.compile.AstQuery
 import org.apache.groovy.lsp.internal.compile.CompilationSnapshot
-import org.apache.groovy.lsp.internal.compile.CompiledDocument
 import org.apache.groovy.lsp.internal.compile.CompilerSettings
 import org.apache.groovy.lsp.internal.compile.GroovyCompiler
 import org.apache.groovy.lsp.internal.compile.Identifiers
@@ -33,11 +32,11 @@ import org.apache.groovy.lsp.internal.compile.WorkspaceScanner
 import org.apache.groovy.lsp.internal.position.PositionEncoding
 import org.apache.groovy.lsp.internal.position.Positions
 import org.apache.groovy.lsp.internal.protocol.ClientFeatures
+import org.apache.groovy.lsp.internal.intelligence.StaticTypeCheckingPass
+import org.apache.groovy.lsp.internal.protocol.GroovyWorkspaceService
 import org.apache.groovy.lsp.internal.util.GroovyKeywords
 import org.apache.groovy.lsp.internal.util.Uris
 import org.apache.groovy.lsp.internal.workspace.TextDocument
-import org.apache.groovy.lsp.spi.GroovyLspExtension
-import org.apache.groovy.lsp.spi.GroovyLspSession
 import org.codehaus.groovy.ast.ASTNode
 import org.codehaus.groovy.ast.ClassHelper
 import org.codehaus.groovy.ast.ClassNode
@@ -51,16 +50,14 @@ import org.codehaus.groovy.ast.expr.MethodCallExpression
 import org.codehaus.groovy.ast.expr.PropertyExpression
 import org.codehaus.groovy.ast.expr.StaticMethodCallExpression
 import org.codehaus.groovy.ast.expr.VariableExpression
-import org.codehaus.groovy.control.CompilerConfiguration
 import org.eclipse.lsp4j.ClientCapabilities
+import org.eclipse.lsp4j.DidChangeConfigurationParams
 import org.eclipse.lsp4j.DeclarationCapabilities
 import org.eclipse.lsp4j.DefinitionCapabilities
 import org.eclipse.lsp4j.DocumentSymbolCapabilities
 import org.eclipse.lsp4j.GeneralClientCapabilities
 import org.eclipse.lsp4j.ImplementationCapabilities
 import org.eclipse.lsp4j.InitializeParams
-import org.eclipse.lsp4j.Position
-import org.eclipse.lsp4j.Range
 import org.eclipse.lsp4j.TextDocumentClientCapabilities
 import org.eclipse.lsp4j.TypeDefinitionCapabilities
 import org.eclipse.lsp4j.WindowClientCapabilities
@@ -70,6 +67,7 @@ import org.eclipse.lsp4j.WorkspaceFolder
 import org.junit.jupiter.api.Test
 
 import java.nio.file.Files
+import java.util.concurrent.TimeUnit
 
 final class HelperCoverageTest {
 
@@ -81,6 +79,7 @@ final class HelperCoverageTest {
         assert !ClientFeatures.declarationLinks(null)
         assert !ClientFeatures.hierarchicalDocumentSymbols(null)
         assert !ClientFeatures.applyEdit(null)
+        assert !ClientFeatures.workspaceConfiguration(null)
         assert !ClientFeatures.workDoneProgress(null)
         def caps = new ClientCapabilities()
         assert !ClientFeatures.definitionLinks(caps)
@@ -95,6 +94,7 @@ final class HelperCoverageTest {
         caps.textDocument = text
         def workspace = new WorkspaceClientCapabilities()
         workspace.applyEdit = true
+        workspace.configuration = true
         caps.workspace = workspace
         def window = new WindowClientCapabilities()
         window.workDoneProgress = true
@@ -106,6 +106,7 @@ final class HelperCoverageTest {
         assert ClientFeatures.declarationLinks(caps)
         assert ClientFeatures.hierarchicalDocumentSymbols(caps)
         assert ClientFeatures.applyEdit(caps)
+        assert ClientFeatures.workspaceConfiguration(caps)
         assert ClientFeatures.workDoneProgress(caps)
     }
 
@@ -159,6 +160,25 @@ final class HelperCoverageTest {
         assert loader != null
         loader.close()
         def on = new CompilerSettings(['.'], ['src'], 3, true, true)
+        def merged = CompilerSettings.fromClientMap(null, on)
+        assert merged.classpath == on.classpath
+        assert CompilerSettings.fromClientMap([:], on).is(on)
+        def fromNull = CompilerSettings.fromClientMap(null, null)
+        assert fromNull.classpath.isEmpty()
+        def applied = CompilerSettings.fromClientMap(
+                [classpath: ['b.jar'], sourcePaths: ['lib'], grapeEnabled: true,
+                 astTestEnabled: true, extraKey: 'v', listed: ['a', null, 'b'], blank: null, (null): 'x'],
+                new CompilerSettings(['a.jar'], ['src'], 3, false, false, [keep: '1']))
+        assert applied.classpath == ['b.jar']
+        assert applied.sourcePaths == ['lib']
+        assert applied.grapeEnabled
+        assert applied.astTestEnabled
+        assert applied.extra.keep == '1'
+        assert applied.extra.extraKey == 'v'
+        assert applied.extra.listed == '[a, b]'
+        assert applied.extra.blank == ''
+        def keepLists = CompilerSettings.fromClientMap([grapeEnabled: false], applied)
+        assert keepLists.classpath == ['b.jar']
         def config = on.toConfiguration()
         assert !config.disabledGlobalASTTransformations.contains('groovy.grape.GrabAnnotationTransformation')
         on.createClassLoader(HelperCoverageTest.classLoader).withCloseable { urls ->
@@ -212,66 +232,59 @@ final class HelperCoverageTest {
     }
 
     @Test
-    void extensionHostIsolatesThrowingPlugins() {
-        def boom = new GroovyLspExtension() {
-            void configure(CompilerConfiguration configuration) { throw new IllegalStateException('c') }
-            void afterCompile(unit) { throw new IllegalStateException('a') }
-            List extraDiagnostics(URI uri, String text) { throw new IllegalStateException('d') }
-            List extraCodeActions(URI uri, List diagnostics, Range range) { throw new IllegalStateException('x') }
-            List<String> commands() { ['boom'] }
-            Object executeCommand(String command, List arguments, GroovyLspSession session) {
-                throw new IllegalStateException('e')
-            }
-        }
-        def host = ExtensionHost.of(null, boom)
-        host.configure(new CompilerConfiguration())
-        host.afterCompile(null)
-        assert host.extraDiagnostics(null).isEmpty()
-        def doc = new CompiledDocument(
-                URI.create('file:///tmp/X.groovy'), 1, 'class X {}', null, null, null)
-        assert host.extraDiagnostics(doc).isEmpty()
-        def td = new TextDocument(URI.create('file:///tmp/X.groovy'), 'groovy', 1, 'class X {}')
-        assert host.extraCodeActions(td, [],
-                new Range(new Position(0, 0), new Position(0, 1))).isEmpty()
-        assert host.executeCommand('boom', [], new GroovyLspSession() {
-            void applyEdit(WorkspaceEdit edit) {}
-        }) == null
-        assert host.executeCommand(null, [], null) == null
-        assert ExtensionHost.of((GroovyLspExtension[]) null) == ExtensionHost.none()
-        assert 'groovy.lsp.stc' in ExtensionHost.discover(null).ids()
-        assert host == ExtensionHost.of(boom)
-        assert host.hashCode() == ExtensionHost.of(boom).hashCode()
-        assert host.all().size() == 1
-        def defaults = new GroovyLspExtension() {}
-        assert defaults.id() == defaults.class.name
-        defaults.configure(new CompilerConfiguration())
-        defaults.afterCompile(null)
-        assert defaults.extraDiagnostics(URI.create('file:///tmp/X.groovy'), '').isEmpty()
-        assert defaults.extraDiagnostics(URI.create('file:///tmp/X.groovy'), '', null).isEmpty()
-        assert defaults.extraCodeActions(URI.create('file:///tmp/X.groovy'), [], null).isEmpty()
-        assert defaults.extraCodeActions(URI.create('file:///tmp/X.groovy'), 'class X {}', [], null).isEmpty()
-        assert defaults.commands().isEmpty()
-        assert defaults.executeCommand('x', [], null) == null
-        defaults.initialized(null)
-        defaults.shutdown()
-        def session = new GroovyLspSession() {
-            void applyEdit(WorkspaceEdit edit) {}
-        }
-        assert session.documentText(URI.create('file:///tmp/X.groovy')) == null
-        assert session.workspaceFolders().isEmpty()
-        assert session.classpath().isEmpty()
-        assert session.sourcePaths().isEmpty()
-        assert session.extraSettings().isEmpty()
-        assert session.extraSetting('x') == null
-        assert session.positionEncoding() == 'utf-16'
-        def blank = new GroovyLspExtension() {
-            String id() { '' }
-            List extraCodeActions(URI uri, String sourceText, List diagnostics, Range range) { null }
-        }
-        assert blank.class.name in ExtensionHost.of(blank).ids()
-        def blankDoc = new TextDocument(URI.create('file:///tmp/X.groovy'), 'groovy', 1, 'class X {}')
-        assert ExtensionHost.of(blank).extraCodeActions(blankDoc, [],
-                new Range(new Position(0, 0), new Position(0, 1))).isEmpty()
+    void contextSessionHelpersAndConfigurationPull() {
+        def context = new LanguageServerContext()
+        assert context.documentText(null) == null
+        assert context.documentText(URI.create('file:///tmp/missing.groovy')) == null
+        assert context.extraSetting(null) == null
+        assert context.extraSetting('x') == null
+        assert context.extraSettings().isEmpty()
+        assert context.classpath().isEmpty()
+        assert context.sourcePaths().isEmpty()
+        assert context.positionEncoding() == 'utf-16'
+        assert context.workspaceFolders().isEmpty()
+
+        def ws = new GroovyWorkspaceService(context)
+        ws.pullConfiguration()
+        def caps = new ClientCapabilities()
+        def workspace = new WorkspaceClientCapabilities()
+        workspace.configuration = true
+        caps.workspace = workspace
+        context.clientCapabilities = caps
+        ws.pullConfiguration()
+
+        def fixture = new LspFixture(context)
+        fixture.server.context.clientCapabilities.workspace.configuration = true
+        fixture.configurationSection = [classpath: ['lib/a.jar'], grapeEnabled: true, dependencies: 'g:a:1']
+        fixture.server.workspaceService.pullConfiguration()
+        assert 'lib/a.jar' in fixture.server.context.classpath()
+        assert fixture.server.context.settings.grapeEnabled
+        assert fixture.server.context.extraSetting('dependencies') == 'g:a:1'
+
+        fixture.configurationReply = []
+        fixture.server.workspaceService.pullConfiguration()
+        fixture.configurationReply = ['not-a-map']
+        fixture.server.workspaceService.pullConfiguration()
+        fixture.configurationReply = [null]
+        fixture.server.workspaceService.pullConfiguration()
+        fixture.configurationCompletesNull = true
+        fixture.server.workspaceService.pullConfiguration()
+        fixture.configurationCompletesNull = false
+        fixture.configurationReturnsNullFuture = true
+        fixture.server.workspaceService.pullConfiguration()
+        fixture.configurationReturnsNullFuture = false
+        fixture.failConfiguration = true
+        fixture.server.workspaceService.pullConfiguration()
+        fixture.failConfiguration = false
+        fixture.configurationHangs = true
+        long started = System.nanoTime()
+        fixture.server.workspaceService.pullConfiguration()
+        assert System.nanoTime() - started < TimeUnit.MILLISECONDS.toNanos(200)
+        def uri = fixture.open('Hello.groovy', 'class Hello {}\n')
+        fixture.server.context.documents.clear()
+        assert fixture.server.context.documentText(URI.create(uri))?.contains('class Hello')
+        fixture.server.workspaceService.didChangeConfiguration(new DidChangeConfigurationParams('not-a-map'))
+        fixture.close()
     }
 
     @Test
@@ -348,7 +361,7 @@ final class HelperCoverageTest {
     @Test
     void privateUtilityConstructors() {
         [Identifiers, PackageGuess, ClientFeatures, GroovyKeywords, Uris,
-         AstQuery, Positions].each { Class type ->
+         AstQuery, Positions, StaticTypeCheckingPass].each { Class type ->
             def ctor = type.getDeclaredConstructor()
             ctor.accessible = true
             ctor.newInstance()
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/LspFixture.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/LspFixture.groovy
index 85ed873124..23065bb0a8 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/LspFixture.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/LspFixture.groovy
@@ -22,6 +22,7 @@ import org.apache.groovy.lsp.GroovyLanguageServer
 import org.eclipse.lsp4j.ApplyWorkspaceEditParams
 import org.eclipse.lsp4j.ApplyWorkspaceEditResponse
 import org.eclipse.lsp4j.ClientCapabilities
+import org.eclipse.lsp4j.ConfigurationParams
 import org.eclipse.lsp4j.DeclarationCapabilities
 import org.eclipse.lsp4j.DefinitionCapabilities
 import org.eclipse.lsp4j.Diagnostic
@@ -57,6 +58,12 @@ class LspFixture implements LanguageClient {
     final List<WorkDoneProgressCreateParams> progressCreates = []
     final List<ProgressParams> progressNotifications = []
     boolean failProgress
+    boolean failConfiguration
+    boolean configurationReturnsNullFuture
+    boolean configurationCompletesNull
+    boolean configurationHangs
+    List<Object> configurationReply
+    Map configurationSection
 
     LspFixture() {
         this(new LanguageServerContext())
@@ -139,6 +146,26 @@ class LspFixture implements LanguageClient {
         messages.add(message)
     }
 
+    @Override
+    CompletableFuture<List<Object>> configuration(ConfigurationParams params) {
+        if (failConfiguration) {
+            throw new IllegalStateException('no config')
+        }
+        if (configurationHangs) {
+            return new CompletableFuture<List<Object>>()
+        }
+        if (configurationReturnsNullFuture) {
+            return null
+        }
+        if (configurationCompletesNull) {
+            return CompletableFuture.completedFuture(null)
+        }
+        if (configurationReply != null) {
+            return CompletableFuture.completedFuture(configurationReply)
+        }
+        CompletableFuture.completedFuture([configurationSection])
+    }
+
     @Override
     CompletableFuture<Void> createProgress(WorkDoneProgressCreateParams params) {
         if (failProgress) {
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingPassTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingPassTest.groovy
new file mode 100644
index 0000000000..ecaac94359
--- /dev/null
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/intelligence/StaticTypeCheckingPassTest.groovy
@@ -0,0 +1,232 @@
+/*
+ *  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.groovy.lsp.internal.intelligence
+
+import groovy.transform.TypeChecked
+import org.apache.groovy.lsp.internal.LspFixture
+import org.apache.groovy.lsp.internal.compile.AstQuery
+import org.apache.groovy.lsp.internal.compile.CompilerSettings
+import org.codehaus.groovy.ast.AnnotationNode
+import org.codehaus.groovy.ast.ClassHelper
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.MethodNode
+import org.codehaus.groovy.ast.ModuleNode
+import org.codehaus.groovy.ast.expr.MethodCallExpression
+import org.codehaus.groovy.control.CompilationFailedException
+import org.codehaus.groovy.control.CompilationUnit
+import org.codehaus.groovy.control.Phases
+import org.codehaus.groovy.transform.stc.StaticTypesMarker
+import org.eclipse.lsp4j.HoverParams
+import org.eclipse.lsp4j.Position
+import org.eclipse.lsp4j.TextDocumentIdentifier
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+import org.objectweb.asm.Opcodes
+
+final class StaticTypeCheckingPassTest {
+
+    private LspFixture fixture
+
+    @AfterEach
+    void tearDown() {
+        fixture?.close()
+    }
+
+    @Test
+    void applyNullAndEmptyUnitAreNoOps() {
+        StaticTypeCheckingPass.apply(null)
+        StaticTypeCheckingPass.apply(new CompilationUnit())
+        StaticTypeCheckingPass.bindTargets(null)
+    }
+
+    @Test
+    void uncompiledSourceHasNoModule() {
+        def unit = new CompilationUnit()
+        unit.addSource('Empty.groovy', 'class Empty {}')
+        StaticTypeCheckingPass.apply(unit)
+    }
+
+    @Test
+    void typeCheckedCallIsBound() {
+        def unit = unitOf('Hello.groovy', '''\
+            @groovy.transform.TypeChecked
+            class Hello {
+                def foo(String name) { foo("x") }
+            }
+            '''.stripIndent())
+        def call = firstCall(moduleOf(unit), 'foo')
+        assert call?.methodTarget != null
+        assert call.methodTarget.name == 'foo'
+    }
+
+    @Test
+    void compileStaticCallIsBound() {
+        def unit = unitOf('Hello.groovy', '''\
+            @groovy.transform.CompileStatic
+            class Hello {
+                def foo(String name) { foo("x") }
+            }
+            '''.stripIndent())
+        assert firstCall(moduleOf(unit), 'foo')?.methodTarget != null
+    }
+
+    @Test
+    void methodLevelTypeCheckedCallIsBound() {
+        def unit = unitOf('Hello.groovy', '''\
+            class Hello {
+                @groovy.transform.TypeChecked
+                def foo(String name) { foo("x") }
+                def bar() { bar() }
+            }
+            '''.stripIndent())
+        def module = moduleOf(unit)
+        assert firstCall(module, 'foo')?.methodTarget != null
+        assert firstCall(module, 'bar')?.methodTarget == null
+    }
+
+    @Test
+    void skipModeClassIsNotBound() {
+        def unit = unitOf('Hello.groovy', '''\
+            import groovy.transform.TypeChecked
+            import groovy.transform.TypeCheckingMode
+            @TypeChecked(TypeCheckingMode.SKIP)
+            class Hello {
+                def foo(String name) { foo("x") }
+            }
+            '''.stripIndent())
+        assert firstCall(moduleOf(unit), 'foo')?.methodTarget == null
+    }
+
+    @Test
+    void unannotatedCallIsNotBound() {
+        def unit = unitOf('Hello.groovy', '''\
+            class Hello {
+                def foo(String name) { foo("x") }
+            }
+            '''.stripIndent())
+        assert firstCall(moduleOf(unit), 'foo')?.methodTarget == null
+    }
+
+    @Test
+    void typeCheckedLocalUsesInferredTypeOnHover() {
+        fixture = new LspFixture()
+        def src = '''\
+            @groovy.transform.TypeChecked
+            class Hello {
+                def foo() {
+                    def n = "x"
+                    n
+                }
+            }
+            '''.stripIndent()
+        def uri = fixture.open('Hello.groovy', src)
+        def hover = fixture.server.textDocumentService.hover(
+                new HoverParams(new TextDocumentIdentifier(uri), new Position(3, 12))).get()
+        assert hover != null
+        def value = hover.contents.isRight() ? hover.contents.getRight().value : hover.contents.getLeft()
+        assert value.toString().contains('String')
+    }
+
+    @Test
+    void typeCheckedUnknownCallAddsAnError() {
+        def unit = unitOf('Hello.groovy', '''\
+            @groovy.transform.TypeChecked
+            class Hello {
+                def foo() { unknownMethod() }
+            }
+            '''.stripIndent())
+        assert unit.errorCollector.errorCount > 0
+    }
+
+    @Test
+    void throwingClassDoesNotDropTheUnit() {
+        def unit = unitOf('Hello.groovy', '''\
+            @groovy.transform.TypeChecked
+            class Hello {
+                def foo(String name) { foo("x") }
+            }
+            '''.stripIndent())
+        def source = unit.iterator().next()
+        def call = firstCall(source.AST, 'foo')
+        source.AST.classes.add(null)
+        source.AST.classes.add(new ExplodingClassNode())
+        StaticTypeCheckingPass.apply(unit)
+        assert call?.methodTarget != null
+    }
+
+    @Test
+    void bindTargetsCopiesMethodNodeMetadataOnly() {
+        def unit = unitOf('Hello.groovy', '''\
+            @groovy.transform.TypeChecked
+            class Hello {
+                def foo(String name) { foo("x") }
+            }
+            '''.stripIndent())
+        def module = moduleOf(unit)
+        def call = firstCall(module, 'foo')
+        def bound = call.methodTarget
+        assert bound != null
+        StaticTypeCheckingPass.apply(unit)
+        assert call.methodTarget == bound
+        call.methodTarget = null
+        call.putNodeMetaData(StaticTypesMarker.DIRECT_METHOD_CALL_TARGET, 'not-a-method')
+        StaticTypeCheckingPass.bindTargets(module.classes[0])
+        assert call.methodTarget == null
+        call.putNodeMetaData(StaticTypesMarker.DIRECT_METHOD_CALL_TARGET, bound)
+        StaticTypeCheckingPass.bindTargets(module.classes[0])
+        assert call.methodTarget == bound
+    }
+
+    private static CompilationUnit unitOf(String name, String src) {
+        def unit = new CompilationUnit(CompilerSettings.defaults().toConfiguration())
+        unit.addSource(name, src)
+        try {
+            unit.compile(Phases.SEMANTIC_ANALYSIS)
+        } catch (CompilationFailedException ignored) {
+        }
+        StaticTypeCheckingPass.apply(unit)
+        unit
+    }
+
+    private static ModuleNode moduleOf(CompilationUnit unit) {
+        unit.iterator().next().AST
+    }
+
+    private static MethodCallExpression firstCall(ModuleNode module, String name) {
+        def call = null
+        AstQuery.walk(module, { node, ctx ->
+            if (node instanceof MethodCallExpression && node.methodAsString == name && call == null) {
+                call = node
+            }
+        })
+        call
+    }
+
+    private static final class ExplodingClassNode extends ClassNode {
+        ExplodingClassNode() {
+            super('Explode', Opcodes.ACC_PUBLIC, ClassHelper.OBJECT_TYPE)
+            addAnnotation(new AnnotationNode(ClassHelper.make(TypeChecked)))
+        }
+
+        @Override
+        List<MethodNode> getMethods() {
+            throw new IllegalStateException('boom')
+        }
+    }
+}
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/protocol/ProtocolCoverageTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/protocol/ProtocolCoverageTest.groovy
index ea0037f53c..4289330d9b 100644
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/protocol/ProtocolCoverageTest.groovy
+++ b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/protocol/ProtocolCoverageTest.groovy
@@ -30,6 +30,7 @@ import org.eclipse.lsp4j.DocumentFormattingParams
 import org.eclipse.lsp4j.DocumentHighlightParams
 import org.eclipse.lsp4j.HoverParams
 import org.eclipse.lsp4j.ImplementationParams
+import org.eclipse.lsp4j.InitializedParams
 import org.eclipse.lsp4j.PrepareRenameParams
 import org.eclipse.lsp4j.ReferenceContext
 import org.eclipse.lsp4j.ReferenceParams
@@ -127,11 +128,18 @@ final class ProtocolCoverageTest {
     @Test
     void configurationAndFolderChangesUpdateSettings() {
         fixture = new LspFixture()
+        def uri = fixture.open('Hello.groovy', 'class Hello {}\n')
         fixture.server.workspaceService.didChangeConfiguration(new DidChangeConfigurationParams(
-                [groovy: [grapeEnabled: true, astTestEnabled: true, classpath: ['.', null], sourcePaths: ['src']]]))
+                [groovy: [grapeEnabled: true, astTestEnabled: true, classpath: ['.', null],
+                          sourcePaths: ['src'], dependencies: 'g:a:1']]))
         assert fixture.server.context.settings.grapeEnabled
         assert fixture.server.context.settings.astTestEnabled
         assert '.' in fixture.server.context.settings.classpath
+        assert fixture.server.context.extraSetting('dependencies') == 'g:a:1'
+        assert fixture.server.context.extraSetting(null) == null
+        assert fixture.server.context.documentText(URI.create(uri))?.contains('class Hello')
+        assert fixture.server.context.positionEncoding() in ['utf-16', 'utf-8', 'utf-32']
+        assert 'src' in fixture.server.context.sourcePaths()
         fixture.server.workspaceService.didChangeConfiguration(new DidChangeConfigurationParams(
                 [grapeEnabled: false, classpath: 'not-a-list']))
         assert !fixture.server.context.settings.grapeEnabled
@@ -145,6 +153,11 @@ final class ProtocolCoverageTest {
         assert !fixture.server.context.workspaceFolders.contains(Uris.normalize(folder.toUri()))
         fixture.server.workspaceService.didChangeWorkspaceFolders(new DidChangeWorkspaceFoldersParams(
                 new WorkspaceFoldersChangeEvent([], [])))
+        fixture.configurationSection = [classpath: ['from-pull.jar'], gdsl: 'x.gdsl']
+        fixture.server.context.clientCapabilities.workspace.configuration = true
+        fixture.server.initialized(new InitializedParams())
+        assert 'from-pull.jar' in fixture.server.context.classpath()
+        assert fixture.server.context.extraSetting('gdsl') == 'x.gdsl'
     }
 
     @Test
diff --git a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/spi/GroovyLspExtensionTest.groovy b/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/spi/GroovyLspExtensionTest.groovy
deleted file mode 100644
index 2c43ae9ade..0000000000
--- a/subprojects/groovy-lsp/src/test/groovy/org/apache/groovy/lsp/internal/spi/GroovyLspExtensionTest.groovy
+++ /dev/null
@@ -1,250 +0,0 @@
-/*
- *  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.groovy.lsp.internal.spi
-
-import org.apache.groovy.lsp.internal.ExtensionHost
-import org.apache.groovy.lsp.internal.LanguageServerContext
-import org.apache.groovy.lsp.internal.LspFixture
-import org.apache.groovy.lsp.internal.compile.AstQuery
-import org.apache.groovy.lsp.internal.feature.SupportServices
-import org.apache.groovy.lsp.internal.position.PositionEncoding
-import org.apache.groovy.lsp.internal.protocol.ServerCapabilityFactory
-import org.apache.groovy.lsp.spi.GroovyLspExtension
-import org.apache.groovy.lsp.spi.GroovyLspSession
-import org.codehaus.groovy.control.CompilationUnit
-import org.eclipse.lsp4j.CodeAction
-import org.eclipse.lsp4j.CodeActionContext
-import org.eclipse.lsp4j.Command
-import org.eclipse.lsp4j.CodeActionParams
-import org.eclipse.lsp4j.Diagnostic
-import org.eclipse.lsp4j.DocumentDiagnosticParams
-import org.eclipse.lsp4j.DidChangeConfigurationParams
-import org.eclipse.lsp4j.ExecuteCommandParams
-import org.eclipse.lsp4j.HoverParams
-import org.eclipse.lsp4j.Position
-import org.eclipse.lsp4j.Range
-import org.eclipse.lsp4j.TextDocumentIdentifier
-import org.codehaus.groovy.ast.ModuleNode
-import org.codehaus.groovy.control.CompilerConfiguration
-import org.codehaus.groovy.ast.expr.MethodCallExpression
-import org.eclipse.lsp4j.jsonrpc.messages.Either
-import org.junit.jupiter.api.AfterEach
-import org.junit.jupiter.api.Test
-
-final class GroovyLspExtensionTest {
-
-    private LspFixture fixture
-
-    @AfterEach
-    void tearDown() {
-        fixture?.close()
-    }
-
-    @Test
-    void afterCompileCommandDiagnosticsAndCodeAction() {
-        def plugin = new RecordingExtension()
-        fixture = new LspFixture(new LanguageServerContext(ExtensionHost.of(plugin)))
-        def uri = fixture.open('Hello.groovy', 'class Hello {}\n')
-        assert plugin.compiles >= 1
-        assert fixture.publishedDiagnostics().any {
-            SupportServices.diagnosticMessage(it) == 'from-plugin'
-        }
-        def result = fixture.server.workspaceService.executeCommand(
-                new ExecuteCommandParams('groovy.lsp.extension.ping', [])).get()
-        assert result == 'pong'
-        def actions = fixture.server.textDocumentService.codeAction(
-                new CodeActionParams(new TextDocumentIdentifier(uri),
-                        new Range(new Position(0, 0), new Position(0, 1)),
-                        new CodeActionContext([]))).get()
-        assert actions.any { it.isRight() && it.getRight().title == 'Plugin action' }
-        def caps = new ServerCapabilityFactory().create(PositionEncoding.UTF16, plugin.commands())
-        assert 'groovy.lsp.extension.ping' in caps.executeCommandProvider.commands
-        assert 'groovy.lsp.organizeImports' in caps.executeCommandProvider.commands
-        def pull = fixture.server.textDocumentService.diagnostic(
-                new DocumentDiagnosticParams(new TextDocumentIdentifier(uri))).get()
-        def items = pull.getLeft().items
-        assert items.any { SupportServices.diagnosticMessage(it) == 'from-plugin' }
-    }
-
-    @Test
-    void throwingPluginDoesNotAbortCompile() {
-        def boom = new GroovyLspExtension() {
-            void afterCompile(CompilationUnit unit) {
-                throw new IllegalStateException('boom')
-            }
-        }
-        fixture = new LspFixture(new LanguageServerContext(ExtensionHost.of(boom)))
-        def uri = fixture.open('Hello.groovy', 'class Hello {}\n')
-        assert fixture.server.context.snapshot.get(uri).module != null
-    }
-
-    @Test
-    void unknownCommandStillReturnsNullWithoutPlugins() {
-        fixture = new LspFixture()
-        assert fixture.server.workspaceService.executeCommand(
-                new ExecuteCommandParams('unknown', [])).get() == null
-        assert ExtensionHost.none().ids().isEmpty()
-        assert ExtensionHost.of().ids().isEmpty()
-        assert 'groovy.lsp.stc' in ExtensionHost.discover(GroovyLspExtensionTest.classLoader).ids()
-    }
-
-    @Test
-    void sessionExposesFoldersTextSettingsAndEncoding() {
-        def plugin = new RecordingExtension()
-        fixture = new LspFixture(new LanguageServerContext(ExtensionHost.of(plugin)))
-        def uri = fixture.open('Hello.groovy', 'class Hello {}\n')
-        fixture.server.workspaceService.didChangeConfiguration(new DidChangeConfigurationParams(
-                [groovy: [classpath: ['lib/a.jar'], dependencies: 'g:a:1', gdsl: 'gdsl.groovy']]))
-        assert plugin.initializedSessions == 1
-        def session = fixture.server.context
-        assert session.documentText(URI.create(uri))?.contains('class Hello')
-        assert session.workspaceFolders() != null
-        assert session.extraSetting('dependencies') == 'g:a:1'
-        assert session.extraSetting('gdsl') == 'gdsl.groovy'
-        assert session.extraSetting(null) == null
-        assert session.positionEncoding() in ['utf-16', 'utf-8', 'utf-32']
-        assert 'lib/a.jar' in session.classpath()
-        assert plugin.configuredWithSession
-        assert plugin.lastModule != null
-        assert plugin.lastSourceText?.contains('class Hello')
-        fixture.close()
-        fixture = null
-        assert plugin.shutdowns == 1
-    }
-
-    @Test
-    void throwingIdAndCommandsDoNotAbortHost() {
-        def boom = new GroovyLspExtension() {
-            String id() { throw new IllegalStateException('id') }
-            List<String> commands() { throw new IllegalStateException('commands') }
-        }
-        def host = ExtensionHost.of(boom)
-        assert boom.class.name in host.ids()
-        assert host.commands().isEmpty()
-    }
-
-    @Test
-    void typeCheckedCallIsBoundByDefaultExtension() {
-        fixture = new LspFixture()
-        def src = '''\
-            @groovy.transform.TypeChecked
-            class Hello {
-                def foo(String name) { foo("x") }
-            }
-            '''.stripIndent()
-        def uri = fixture.open('Hello.groovy', src)
-        def module = fixture.server.context.snapshot.get(uri).module
-        def call = null
-        AstQuery.walk(module, { node, ctx ->
-            if (node instanceof MethodCallExpression && node.methodAsString == 'foo') {
-                call = node
-            }
-        })
-        assert call?.methodTarget != null
-        assert call.methodTarget.name == 'foo'
-    }
-
-    @Test
-    void typeCheckedLocalUsesInferredTypeOnHover() {
-        fixture = new LspFixture()
-        def src = '''\
-            @groovy.transform.TypeChecked
-            class Hello {
-                def foo() {
-                    def n = "x"
-                    n
-                }
-            }
-            '''.stripIndent()
-        def uri = fixture.open('Hello.groovy', src)
-        def hover = fixture.server.textDocumentService.hover(
-                new HoverParams(new TextDocumentIdentifier(uri), new Position(3, 12))).get()
-        assert hover != null
-        def value = hover.contents.isRight() ? hover.contents.getRight().value : hover.contents.getLeft()
-        assert value.toString().contains('String')
-    }
-
-    private static final class RecordingExtension implements GroovyLspExtension {
-        int compiles
-        int initializedSessions
-        int shutdowns
-        boolean configuredWithSession
-        ModuleNode lastModule
-        String lastSourceText
-
-        @Override
-        String id() {
-            'test.ping'
-        }
-
-        @Override
-        void configure(CompilerConfiguration configuration, GroovyLspSession session) {
-            configuredWithSession = session != null
-        }
-
-        @Override
-        void initialized(GroovyLspSession session) {
-            initializedSessions++
-        }
-
-        @Override
-        void shutdown() {
-            shutdowns++
-        }
-
-        @Override
-        void afterCompile(CompilationUnit unit) {
-            compiles++
-        }
-
-        @Override
-        List<Diagnostic> extraDiagnostics(URI uri, String sourceText, ModuleNode module) {
-            lastModule = module
-            lastSourceText = sourceText
-            extraDiagnostics(uri, sourceText)
-        }
-
-        @Override
-        List<Diagnostic> extraDiagnostics(URI uri, String sourceText) {
-            [new Diagnostic(new Range(new Position(0, 0), new Position(0, 1)), 'from-plugin')]
-        }
-
-        @Override
-        List<Either<Command, CodeAction>> extraCodeActions(URI uri, String sourceText,
-                                                           List<Diagnostic> diagnostics, Range range) {
-            lastSourceText = sourceText
-            extraCodeActions(uri, diagnostics, range)
-        }
-
-        @Override
-        List<Either<Command, CodeAction>> extraCodeActions(URI uri, List<Diagnostic> diagnostics, Range range) {
-            [Either.forRight(new CodeAction('Plugin action'))]
-        }
-
-        @Override
-        List<String> commands() {
-            ['groovy.lsp.extension.ping']
-        }
-
-        @Override
-        Object executeCommand(String command, List<Object> arguments, GroovyLspSession session) {
-            'pong'
-        }
-    }
-}