You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@kie.apache.org by tk...@apache.org on 2026/08/25 01:10:01 UTC

(incubator-kie-tools) branch main updated: kie-issues#3712: [drl-vscode-extension] hover improvements, and Java types resolved before a build (#3965)

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

tkobayas pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-kie-tools.git


The following commit(s) were added to refs/heads/main by this push:
     new 47daecc1f91 kie-issues#3712: [drl-vscode-extension] hover improvements, and Java types resolved before a build (#3965)
47daecc1f91 is described below

commit 47daecc1f91ebea19772bac89450e240254122f0
Author: Daniel Clark <da...@gmail.com>
AuthorDate: Tue Aug 25 03:09:56 2026 +0200

    kie-issues#3712: [drl-vscode-extension] hover improvements, and Java types resolved before a build (#3965)
---
 packages/drl-vscode-extension/README.md            |  11 +
 packages/drl-vscode-extension/package.json         |  16 +
 packages/drl-vscode-extension/src/extension.ts     |  23 +-
 .../drools/completion/AccumulateFunctionTypes.java |   8 +-
 .../java/org/drools/completion/ClassIndex.java     |  26 +-
 .../org/drools/completion/ClassMemberIndex.java    | 172 ++++-
 .../drools/completion/ClasspathTypeMembers.java    |  98 +++
 .../org/drools/completion/DRLCompletionHelper.java | 201 ++++++
 .../drools/completion/DRLDeclaredTypeParser.java   |   3 +
 .../org/drools/completion/DRLDefinitionHelper.java |  43 +-
 .../java/org/drools/completion/DRLHoverHelper.java | 431 ++++++++++++-
 .../org/drools/completion/DRLInlayHintHelper.java  |   7 +-
 .../drools/completion/DRLTypeHierarchyHelper.java  |  52 +-
 .../src/main/java/org/drools/completion/Field.java |  11 +-
 .../org/drools/completion/JavaMemberSource.java    |  49 ++
 .../org/drools/completion/JavaSourceRoots.java     | 146 +++++
 .../java/org/drools/completion/JavaSourceType.java |  68 ++
 .../org/drools/completion/JavaSourceTypeIndex.java | 379 ++++++++++++
 .../drools/completion/JavaSourceTypeParser.java    | 462 ++++++++++++++
 .../org/drools/completion/LhsBindingResolver.java  |  86 ++-
 .../java/org/drools/completion/ClassIndexTest.java |  44 ++
 .../drools/completion/ClassMemberIndexTest.java    | 115 +++-
 .../drools/completion/DRLCompletionHelperTest.java | 291 +++++++++
 .../drools/completion/DRLDefinitionHelperTest.java |  81 ++-
 .../org/drools/completion/DRLHoverHelperTest.java  | 689 +++++++++++++++++++++
 .../drools/completion/DRLInlayHintHelperTest.java  |  59 ++
 .../org/drools/completion/DRLLintHelperTest.java   |  28 +
 .../completion/DRLTypeHierarchyHelperTest.java     | 177 +++++-
 .../java/org/drools/completion/FieldTest.java}     |  34 +-
 .../drools/completion/JavaSourceTypeIndexTest.java | 257 ++++++++
 .../completion/JavaSourceTypeParserTest.java       | 162 +++++
 .../drools/completion/LhsBindingResolverTest.java  |  95 +++
 .../lsp/server/DroolsLspDocumentService.java       |  69 ++-
 .../org/drools/lsp/server/DroolsLspServer.java     | 164 ++++-
 .../lsp/server/DroolsLspDocumentServiceTest.java   |  29 +-
 .../lsp/server/JavaSourceTypingServerTest.java     | 183 ++++++
 36 files changed, 4639 insertions(+), 130 deletions(-)

diff --git a/packages/drl-vscode-extension/README.md b/packages/drl-vscode-extension/README.md
index 6b2927e1116..13b95ad00cf 100644
--- a/packages/drl-vscode-extension/README.md
+++ b/packages/drl-vscode-extension/README.md
@@ -128,9 +128,20 @@ Same-named groups from several files are merged, with a warning.
 | `drools.lsp.lint.mvelPropertyAccess` | `off`     | Hint to prefer property-access style over getter calls in LHS |
 | `drools.lsp.inlayHints.enabled`      | `true`    | Show inline type hints for bound variables                    |
 | `drools.lsp.maven.pomPath`           | `""`      | Maven POM path(s) for classpath resolution                    |
+| `drools.lsp.java.sourcePaths`        | `[]`      | Extra Java source roots, beyond those found automatically     |
+| `drools.lsp.java.packageFilters`     | `[]`      | Package prefixes limiting which Java source types are indexed |
 
 All lint settings accept: `off`, `hint`, `info`, `warning`, `error`.
 
+The project's own Java types resolve from `.java` sources, so completion, hover,
+navigation and the unknown-type lint work on a fresh checkout, before Maven has
+produced any class files. Compiled classes take precedence as soon as they
+exist. Every `src/main/java` directory under the workspace is found
+automatically, so `drools.lsp.java.sourcePaths` is only needed for source roots
+that sit elsewhere; each entry is a literal directory path, absolute or
+workspace-relative, not a glob. Both settings are read when the language server
+starts, so changing either needs a restart.
+
 ## Known Issues
 
 If you find any issues, please report them in [GitHub Issues](https://github.com/apache/incubator-kie-issues/issues).
diff --git a/packages/drl-vscode-extension/package.json b/packages/drl-vscode-extension/package.json
index e7d7c478f53..36709db081b 100644
--- a/packages/drl-vscode-extension/package.json
+++ b/packages/drl-vscode-extension/package.json
@@ -182,6 +182,22 @@
           "markdownDescription": "Show inline type hints for DRL rule bindings (e.g. `$count : count()` → `: Long`). VSCode's global `#editor.inlayHints.enabled#` still governs whether inlay hints are displayed at all.",
           "type": "boolean"
         },
+        "drools.lsp.java.packageFilters": {
+          "default": [],
+          "items": {
+            "type": "string"
+          },
+          "markdownDescription": "Package prefixes limiting which Java source types are indexed, e.g. `com.example.*`. Empty means all packages. Applied when the language server next starts.",
+          "type": "array"
+        },
+        "drools.lsp.java.sourcePaths": {
+          "default": [],
+          "items": {
+            "type": "string"
+          },
+          "markdownDescription": "Additional Java source roots used to resolve the project's own types before a build. Every `src/main/java` under the workspace is found automatically, so this is only for roots that sit elsewhere. Each entry is a literal directory path, absolute or workspace-relative — not a glob. Applied when the language server next starts.",
+          "type": "array"
+        },
         "drools.lsp.lint.missingEnd": {
           "default": "warning",
           "description": "Severity for the missing 'end' keyword lint check.",
diff --git a/packages/drl-vscode-extension/src/extension.ts b/packages/drl-vscode-extension/src/extension.ts
index 07ec83664a7..c16e3161d5c 100644
--- a/packages/drl-vscode-extension/src/extension.ts
+++ b/packages/drl-vscode-extension/src/extension.ts
@@ -151,6 +151,22 @@ export async function activate(context: vscode.ExtensionContext) {
       args.push(`-Ddrools.lsp.maven.pomPath=${resolved.join(path.delimiter)}`);
     }
 
+    // Java source roots and package filters for resolving the project's own
+    // types before a build. Joined with ';' regardless of platform, since the
+    // server splits on that; read at spawn, so a change needs a restart.
+    const javaSourcePaths = (config.get<string[]>("drools.lsp.java.sourcePaths") ?? [])
+      .map((p) => p.trim())
+      .filter((p) => p.length > 0);
+    if (javaSourcePaths.length > 0) {
+      args.push(`-Ddrools.lsp.java.sourcePaths=${javaSourcePaths.join(";")}`);
+    }
+    const javaPackageFilters = (config.get<string[]>("drools.lsp.java.packageFilters") ?? [])
+      .map((p) => p.trim())
+      .filter((p) => p.length > 0);
+    if (javaPackageFilters.length > 0) {
+      args.push(`-Ddrools.lsp.java.packageFilters=${javaPackageFilters.join(";")}`);
+    }
+
     args.push("-jar", serverJar);
 
     serverOptions = {
@@ -165,7 +181,12 @@ export async function activate(context: vscode.ExtensionContext) {
     const clientOptions: LanguageClientOptions = {
       documentSelector: [{ scheme: "file", language: "drools" }],
       synchronize: {
-        fileEvents: vscode.workspace.createFileSystemWatcher("**/target/classes/**/*.class"),
+        fileEvents: [
+          vscode.workspace.createFileSystemWatcher("**/target/classes/**/*.class"),
+          // Feeds .java edits into the server's watched-files debounce, so the
+          // source type index tracks them without waiting for a build.
+          vscode.workspace.createFileSystemWatcher("**/*.java"),
+        ],
       },
       outputChannel: channel,
       // Sent with `initialize`, so the server has both the grouping and the file
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/AccumulateFunctionTypes.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/AccumulateFunctionTypes.java
index f5082229b01..5871ac0fe92 100644
--- a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/AccumulateFunctionTypes.java
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/AccumulateFunctionTypes.java
@@ -26,8 +26,8 @@ import java.util.Map;
  * result type), used by {@link LhsBindingResolver} and the inlay-hint helper to
  * resolve accumulate result bindings ({@code $count : count()}).
  *
- * <p>Seeded with the complete set of Drools' built-in accumulate functions. The
- * names and result types are transcribed from the Drools engine itself —
+ * <p>Seeded with Drools 10.2.0's complete set of built-in accumulate functions.
+ * The names and result types are transcribed from the Drools engine itself —
  * {@code META-INF/kie.default.properties.conf} (function name → impl class) and
  * each impl's {@code getResultType()}.
  * Baking them in avoids a runtime dependency on {@code drools-core};
@@ -45,10 +45,14 @@ public final class AccumulateFunctionTypes {
             Map.entry("maxN", "Number"),
             Map.entry("maxI", "Integer"),
             Map.entry("maxL", "Long"),
+            Map.entry("maxBI", "BigInteger"),
+            Map.entry("maxBD", "BigDecimal"),
             Map.entry("min", "Comparable"),
             Map.entry("minN", "Number"),
             Map.entry("minI", "Integer"),
             Map.entry("minL", "Long"),
+            Map.entry("minBI", "BigInteger"),
+            Map.entry("minBD", "BigDecimal"),
             Map.entry("count", "Long"),
             Map.entry("collectList", "List"),
             Map.entry("collectSet", "Set"),
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClassIndex.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClassIndex.java
index 8a2b2339283..97940a4af7e 100644
--- a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClassIndex.java
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClassIndex.java
@@ -23,6 +23,7 @@ import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -47,6 +48,15 @@ public class ClassIndex {
         return new ClassIndex(new HashMap<>());
     }
 
+    /** Builds an index directly from a simple-name → FQCNs map (defensively copied). */
+    public static ClassIndex of(Map<String, List<String>> nameToFqcns) {
+        Map<String, List<String>> index = new HashMap<>();
+        for (Map.Entry<String, List<String>> e : nameToFqcns.entrySet()) {
+            index.put(e.getKey(), new ArrayList<>(e.getValue()));
+        }
+        return new ClassIndex(index);
+    }
+
     public static ClassIndex build(Set<Path> classpathEntries) {
         Map<String, List<String>> index = new HashMap<>();
         for (Path entry : classpathEntries) {
@@ -63,9 +73,9 @@ public class ClassIndex {
         Map<String, List<String>> merged = new HashMap<>(base.index);
         for (Map.Entry<String, List<String>> entry : overlay.index.entrySet()) {
             merged.merge(entry.getKey(), entry.getValue(), (a, b) -> {
-                List<String> combined = new ArrayList<>(a);
+                java.util.LinkedHashSet<String> combined = new java.util.LinkedHashSet<>(a);
                 combined.addAll(b);
-                return combined;
+                return new ArrayList<>(combined);
             });
         }
         return new ClassIndex(merged);
@@ -85,6 +95,18 @@ public class ClassIndex {
         return java.util.Collections.unmodifiableSet(index.keySet());
     }
 
+    /**
+     * The FQCNs carrying exactly {@code simpleName}, or empty. The index is
+     * keyed by simple name, so this is a lookup — unlike {@link #getMatching},
+     * which is a prefix search over every entry and answers for longer names
+     * too. Callers confirming one name should prefer this: they run per pattern
+     * per request, where a scan of the whole index is felt.
+     */
+    public List<String> forSimpleName(String simpleName) {
+        List<String> fqcns = simpleName == null ? null : index.get(simpleName);
+        return fqcns == null ? List.of() : Collections.unmodifiableList(fqcns);
+    }
+
     public List<String> getMatching(String prefix) {
         List<String> result = new ArrayList<>();
         for (Map.Entry<String, List<String>> entry : index.entrySet()) {
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClassMemberIndex.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClassMemberIndex.java
index a52ea369ddf..31eaa94fbdc 100644
--- a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClassMemberIndex.java
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClassMemberIndex.java
@@ -45,6 +45,10 @@ import java.util.logging.Logger;
  * arbitrary user code inside the language server. The loader's parent is the
  * platform class loader, so the server's own classes never leak into user
  * completions. Results (including misses) are cached per FQCN.
+ *
+ * <p>When reflection can't load a type, an optional {@link JavaMemberSource}
+ * fallback (e.g. one backed by uncompiled {@code .java} sources) is consulted
+ * instead — compiled classes always win over the fallback when both know a type.
  */
 public final class ClassMemberIndex implements AutoCloseable {
 
@@ -55,6 +59,8 @@ public final class ClassMemberIndex implements AutoCloseable {
     private final ClassLoader loader;
     private final boolean ownsLoader;
     private final Map<String, List<Field>> cache = new ConcurrentHashMap<>();
+    private final Set<String> notLoadable = ConcurrentHashMap.newKeySet();
+    private volatile JavaMemberSource fallback;
 
     /** Visible for tests: reflect against an existing (externally-owned) loader. */
     ClassMemberIndex(ClassLoader loader) {
@@ -71,10 +77,32 @@ public final class ClassMemberIndex implements AutoCloseable {
         return EMPTY;
     }
 
-    /** Builds an index over the given classpath entries (jars and class dirs). */
+    /**
+     * Sets the fallback consulted when reflection can't load a type — the
+     * server owns the single instance and sets this right after construction.
+     * No-op on the shared {@link #empty()} index. Public so a caller in
+     * another module can install the fallback after building an
+     * {@link #of} instance.
+     */
+    public void setSourceFallback(JavaMemberSource f) {
+        if (this == EMPTY) {
+            logger.log(Level.FINE, "ignoring source fallback on shared empty index");
+            return;
+        }
+        this.fallback = f;
+    }
+
+    /**
+     * Builds an index over the given classpath entries (jars and class dirs).
+     * A {@code null}/empty set yields a fresh, fallback-capable instance (no
+     * loader, so every lookup misses reflection and falls straight through to
+     * a fallback set via {@link #setSourceFallback} — the source-only,
+     * works-before-build workspace) rather than the shared {@link #empty()}
+     * singleton, which never accepts a fallback.
+     */
     public static ClassMemberIndex of(Set<Path> classpathEntries) {
         if (classpathEntries == null || classpathEntries.isEmpty()) {
-            return EMPTY;
+            return new ClassMemberIndex((ClassLoader) null, false);
         }
         List<URL> urls = new ArrayList<>(classpathEntries.size());
         for (Path entry : classpathEntries) {
@@ -92,6 +120,7 @@ public final class ClassMemberIndex implements AutoCloseable {
     @Override
     public void close() {
         cache.clear();
+        notLoadable.clear();
         if (ownsLoader && loader instanceof java.io.Closeable closeable) {
             try {
                 closeable.close();
@@ -104,32 +133,46 @@ public final class ClassMemberIndex implements AutoCloseable {
     /**
      * Returns the completable members of {@code fqcn}: enum constants, bean
      * properties derived from public no-arg {@code getX()}/{@code isX()}
-     * methods (including inherited ones), and public instance fields. Empty
-     * when the class cannot be loaded.
+     * methods (including inherited ones), and public instance fields. When
+     * the class cannot be loaded, consults the {@link #fallback} source (if
+     * set); empty when neither knows the type. Compiled classes win: a
+     * successful reflection load is always used over the fallback.
      */
     public List<Field> membersOf(String fqcn) {
-        if (loader == null || fqcn == null || fqcn.isEmpty()) {
+        if (fqcn == null || fqcn.isEmpty()) {
             return Collections.emptyList();
         }
-        return cache.computeIfAbsent(fqcn, this::reflectMembers);
+        List<Field> cached = cache.get(fqcn);
+        if (cached != null) {
+            return cached;
+        }
+        Class<?> clazz = tryLoad(fqcn);
+        if (clazz == null) {
+            // Not cached: the source index maintains its own mtime-based cache.
+            JavaMemberSource f = fallback;
+            return f != null ? f.membersOf(fqcn) : Collections.emptyList();
+        }
+        return cache.computeIfAbsent(fqcn, key -> reflectMembers(clazz));
     }
 
     /**
-     * Returns the fully-qualified names of {@code fqcn}'s direct supertypes —
-     * its superclass (omitting {@code java.lang.Object}) followed by its
-     * directly-implemented interfaces. Loaded without running static
-     * initializers; empty when the class can't be loaded. Used by type
-     * hierarchy to walk classpath ancestry one level at a time.
+     * Returns {@code fqcn}'s direct supertypes — its superclass (omitting
+     * {@code java.lang.Object}) followed by its directly-implemented
+     * interfaces. Loaded without running static initializers. When the class
+     * can't be loaded, consults the {@link #fallback} source (if set); empty
+     * when neither knows the type. Used by type hierarchy to walk classpath
+     * ancestry one level at a time. Both the reflected path and the {@link
+     * #fallback} path return fully-qualified names — see {@link
+     * JavaMemberSource#supertypesOf} for the fallback's resolution contract.
      */
     public List<String> supertypesOf(String fqcn) {
-        if (loader == null || fqcn == null || fqcn.isEmpty()) {
+        if (fqcn == null || fqcn.isEmpty()) {
             return Collections.emptyList();
         }
-        Class<?> clazz;
-        try {
-            clazz = Class.forName(fqcn, false, loader);
-        } catch (Throwable t) {
-            return Collections.emptyList();
+        Class<?> clazz = tryLoad(fqcn);
+        if (clazz == null) {
+            JavaMemberSource f = fallback;
+            return f != null ? f.supertypesOf(fqcn) : Collections.emptyList();
         }
         try {
             List<String> out = new ArrayList<>();
@@ -151,19 +194,19 @@ public final class ClassMemberIndex implements AutoCloseable {
      * The names addressable as {@code Type.X} on {@code fqcn}: public fields
      * (including enum constants and static fields, inherited included) and
      * public nested-type simple names. Returns {@code null} when the class
-     * cannot be loaded — letting callers distinguish "no such member" (a real
-     * typo) from "couldn't verify" (classpath gap), so they only flag the
+     * cannot be loaded and the {@link #fallback} source (if set) doesn't know
+     * it either — letting callers distinguish "no such member" (a real typo)
+     * from "couldn't verify" (classpath/source gap), so they only flag the
      * former. Loaded without running static initializers.
      */
     public Set<String> memberNames(String fqcn) {
-        if (loader == null || fqcn == null || fqcn.isEmpty()) {
+        if (fqcn == null || fqcn.isEmpty()) {
             return null;
         }
-        Class<?> clazz;
-        try {
-            clazz = Class.forName(fqcn, false, loader);
-        } catch (Throwable t) {
-            return null;
+        Class<?> clazz = tryLoad(fqcn);
+        if (clazz == null) {
+            JavaMemberSource f = fallback;
+            return f != null ? f.memberNames(fqcn) : null;
         }
         try {
             Set<String> names = new LinkedHashSet<>();
@@ -180,20 +223,85 @@ public final class ClassMemberIndex implements AutoCloseable {
         }
     }
 
-    private List<Field> reflectMembers(String fqcn) {
-        Class<?> clazz;
+    /**
+     * Reflected public constructor signatures of {@code fqcn} —
+     * {@code SimpleName(ParamSimple, ParamSimple)}, in the order the JVM
+     * reports them — or the {@link #fallback} source's when the class can't
+     * load, or empty when neither knows the type. Not cached: constructors
+     * are only reflected on hover.
+     */
+    public List<String> constructorsOf(String fqcn) {
+        if (fqcn == null || fqcn.isEmpty()) {
+            return Collections.emptyList();
+        }
+        Class<?> clazz = tryLoad(fqcn);
+        if (clazz == null) {
+            JavaMemberSource f = fallback;
+            return f != null ? f.constructorsOf(fqcn) : Collections.emptyList();
+        }
         try {
-            clazz = Class.forName(fqcn, false, loader);
+            List<String> out = new ArrayList<>();
+            for (java.lang.reflect.Constructor<?> ctor : clazz.getDeclaredConstructors()) {
+                if (!Modifier.isPublic(ctor.getModifiers())) {
+                    continue;
+                }
+                StringBuilder signature = new StringBuilder(clazz.getSimpleName()).append('(');
+                Class<?>[] params = ctor.getParameterTypes();
+                for (int i = 0; i < params.length; i++) {
+                    if (i > 0) {
+                        signature.append(", ");
+                    }
+                    signature.append(params[i].getSimpleName());
+                }
+                signature.append(')');
+                out.add(signature.toString());
+            }
+            return Collections.unmodifiableList(out);
         } catch (Throwable t) {
+            logger.log(Level.FINE, "Failed to reflect constructors of " + fqcn, t);
             return Collections.emptyList();
         }
+    }
+
+    /**
+     * Attempts to load {@code fqcn} without running static initializers.
+     * Returns {@code null} (rather than throwing) when the loader is absent
+     * or the class can't be loaded — the signal callers use to fall back to
+     * {@link #fallback}. A failed load is memoized in {@link #notLoadable} so
+     * repeated lookups of the same unloadable FQCN (the common case for a
+     * works-before-build workspace) skip the {@code Class.forName}
+     * attempt; the fallback itself is still consulted fresh every call.
+     */
+    private Class<?> tryLoad(String fqcn) {
+        if (loader == null || notLoadable.contains(fqcn)) {
+            return null;
+        }
+        try {
+            return Class.forName(fqcn, false, loader);
+        } catch (ClassNotFoundException e) {
+            // Settled: the name is not on this classpath, and asking again on
+            // every keystroke would cost the same answer.
+            notLoadable.add(fqcn);
+            return null;
+        } catch (Throwable t) {
+            // A link error can be transient — a dependency jar rewritten by a
+            // build running alongside the editor — so the name is not written
+            // off. Logged because the effect is otherwise invisible: an
+            // unloadable class also reports "unverifiable" to the lint, which
+            // then stops flagging typos on it.
+            logger.log(Level.FINE, t, () -> "Could not load " + fqcn);
+            return null;
+        }
+    }
+
+    private List<Field> reflectMembers(Class<?> clazz) {
         try {
             Map<String, Field> members = new LinkedHashMap<>();
             if (clazz.isEnum()) {
                 for (java.lang.reflect.Field f : clazz.getFields()) {
                     if (f.isEnumConstant()) {
                         members.put(f.getName(),
-                                new Field(f.getName(), clazz.getSimpleName()));
+                                new Field(f.getName(), clazz.getSimpleName(), null, Field.Origin.ENUM_CONSTANT));
                     }
                 }
             }
@@ -201,19 +309,19 @@ public final class ClassMemberIndex implements AutoCloseable {
                 String property = propertyNameOf(m);
                 if (property != null) {
                     members.putIfAbsent(property,
-                            new Field(property, m.getReturnType().getSimpleName()));
+                            new Field(property, m.getReturnType().getSimpleName(), null, Field.Origin.GETTER));
                 }
             }
             for (java.lang.reflect.Field f : clazz.getFields()) {
                 if (!Modifier.isStatic(f.getModifiers())) {
                     members.putIfAbsent(f.getName(),
-                            new Field(f.getName(), f.getType().getSimpleName()));
+                            new Field(f.getName(), f.getType().getSimpleName(), null, Field.Origin.FIELD));
                 }
             }
             return Collections.unmodifiableList(new ArrayList<>(members.values()));
         } catch (Throwable t) {
             // LinkageError etc. while reflecting — treat as unknown.
-            logger.log(Level.FINE, "Failed to reflect members of " + fqcn, t);
+            logger.log(Level.FINE, "Failed to reflect members of " + clazz.getName(), t);
             return Collections.emptyList();
         }
     }
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClasspathTypeMembers.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClasspathTypeMembers.java
new file mode 100644
index 00000000000..847b2a78fbd
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/ClasspathTypeMembers.java
@@ -0,0 +1,98 @@
+/*
+ * 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.drools.completion;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Function;
+import java.util.logging.Logger;
+
+/**
+ * Members of types the DRL does not declare, supplied by the host.
+ *
+ * <p>A DRL {@code declare} may extend a Java class
+ * ({@code declare Assessment extends InputFact}), and a pattern may name a Java
+ * class directly. In both cases the type's members live on the classpath — or,
+ * before a build, in workspace sources — not in the declared-type index, so the
+ * inheritance walks in {@link DRLDeclaredTypeParser#fieldsIncludingInherited}
+ * and {@link LhsBindingResolver} terminate at the boundary and the inherited
+ * members simply vanish from hover, completion, and binding types.
+ *
+ * <p>The host installs a lookup that resolves a type name to its members with
+ * inherited ones already folded in; this class is the single seam both walks
+ * consult. Absent a lookup every method is inert, so the module works
+ * standalone, and in tests, with no host at all.
+ *
+ * <p>The lookup is keyed by the type name as the DRL writes it, which carries no
+ * import context. A host resolving a bare simple name therefore cannot tell
+ * which of two same-named classes the document meant, and answers for neither.
+ * Closing that needs the document's imports to reach the lookup — the callers
+ * are text-level and hold none today — so the seam would take a resolved name
+ * instead of a simple one.
+ */
+public final class ClasspathTypeMembers {
+
+    private static final Logger logger = Logger.getLogger(ClasspathTypeMembers.class.getName());
+
+    private static volatile Function<String, List<Field>> lookup;
+
+    private ClasspathTypeMembers() {
+    }
+
+    /**
+     * Installs the members lookup, keyed by the type name as written in the DRL
+     * (simple or qualified) and returning members with inherited ones folded in.
+     * {@code null} clears it. The host is expected to read its live type indexes
+     * inside the function so a rebuilt classpath needs no re-install.
+     */
+    public static void install(Function<String, List<Field>> memberLookup) {
+        lookup = memberLookup;
+    }
+
+    /** Members of {@code typeName}, or empty when unknown or no lookup is installed. */
+    static List<Field> membersOf(String typeName) {
+        Function<String, List<Field>> current = lookup;
+        if (current == null || typeName == null || typeName.isEmpty()) {
+            return Collections.emptyList();
+        }
+        try {
+            List<Field> members = current.apply(typeName);
+            return members == null ? Collections.emptyList() : members;
+        } catch (RuntimeException e) {
+            logger.fine(() -> "Member lookup failed for " + typeName + ": " + e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    /**
+     * {@code typeName} as a {@link DeclaredType} stand-in so the declared-type
+     * walks can continue through it, or {@code null} when it has no known
+     * members. The stand-in carries no supertype of its own: the member list is
+     * already flattened, so a walk must stop there rather than looking for a
+     * parent the host has already accounted for.
+     */
+    static DeclaredType asDeclaredType(String typeName) {
+        List<Field> members = membersOf(typeName);
+        if (members.isEmpty()) {
+            return null;
+        }
+        return new DeclaredType(typeName, members, false, 0, 0);
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLCompletionHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLCompletionHelper.java
index ea393cf3d8b..6360758cb19 100644
--- a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLCompletionHelper.java
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLCompletionHelper.java
@@ -20,6 +20,7 @@ package org.drools.completion;
 
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -77,6 +78,19 @@ public class DRLCompletionHelper {
                     DRL10Parser.DRL_BIG_DECIMAL_LITERAL, DRL10Parser.DRL_BIG_INTEGER_LITERAL)
     ).collect(Collectors.toUnmodifiableSet());
 
+    // Rule attributes are only legal in a rule/query header. c3 predicts them at
+    // a statement boundary too (after `end`, or in an empty document), where they
+    // cannot be typed — 14 unusable items next to the 6 that are usable. They are
+    // dropped there only; the rule header keeps offering them.
+    private static final Set<Integer> RULE_ATTRIBUTE_TOKENS = Set.of(
+            DRL10Parser.DRL_ATTRIBUTES, DRL10Parser.DRL_SALIENCE, DRL10Parser.DRL_ENABLED,
+            DRL10Parser.DRL_NO_LOOP, DRL10Parser.DRL_AUTO_FOCUS, DRL10Parser.DRL_LOCK_ON_ACTIVE,
+            DRL10Parser.DRL_ACTIVATION_GROUP, DRL10Parser.DRL_RULEFLOW_GROUP,
+            DRL10Parser.DRL_DATE_EFFECTIVE, DRL10Parser.DRL_DATE_EXPIRES,
+            DRL10Parser.DRL_DIALECT, DRL10Parser.DRL_CALENDARS,
+            DRL10Parser.DRL_TIMER, DRL10Parser.DRL_DURATION
+    );
+
     private DRLCompletionHelper() {
     }
 
@@ -121,6 +135,22 @@ public class DRLCompletionHelper {
                 ? nodeIndex
                 : drlParser.getInputStream().size() - 1;
 
+        // A caret directly after a dot is a member position: the only thing that
+        // can follow is a member of the path's type. c3 predicts the constraint
+        // operators there instead (it cannot see that the path is incomplete), so
+        // this position is answered from the type walk alone, keywords included —
+        // but only when the walk recognises the path. A dot also separates the
+        // segments of a qualified name, where the head names no type and the
+        // walk must leave the position to c3 rather than answer with nothing.
+        String[] chain = dottedChainBeforeCaret(text, caretPosition);
+        if (chain != null) {
+            List<CompletionItem> members = memberItemsForChain(chain, text, caretPosition,
+                    compilationUnit, caretTokenIndex, classIndex, memberIndex, documentPath, openFiles);
+            if (members != null) {
+                return members;
+            }
+        }
+
         // Right after '(' the matched token is the paren itself, for which c3
         // yields no candidates; look one token ahead for the constraint, but
         // keep the paren's index to resolve the pattern type (its span ends at
@@ -148,9 +178,11 @@ public class DRLCompletionHelper {
         }
 
         boolean constraintPosition = compilationUnit != null && isConstraintPosition(candidates);
+        boolean statementBoundary = isStatementBoundary(candidates);
 
         List<CompletionItem> items = candidates.tokens.keySet().stream().filter(Objects::nonNull)
                 .filter(token -> !(constraintPosition && CONSTRAINT_KEYWORD_NOISE.contains(token)))
+                .filter(token -> !(statementBoundary && RULE_ATTRIBUTE_TOKENS.contains(token)))
                 .map(integer -> drlParser.getVocabulary().getDisplayName(integer).replace("'", ""))
                 .map(String::toLowerCase)
                 .map(k -> createCompletionItem(k, CompletionItemKind.Keyword))
@@ -172,6 +204,16 @@ public class DRLCompletionHelper {
         return path != null && path.contains(DRL10Parser.RULE_constraint);
     }
 
+    /**
+     * Whether the caret sits where a whole top-level statement can start, which
+     * c3 reports by predicting the statement keywords together. A rule header
+     * predicts the attributes without them, so the two positions stay distinct.
+     */
+    private static boolean isStatementBoundary(CodeCompletionCore.CandidatesCollection candidates) {
+        return candidates.tokens.containsKey(DRL10Parser.IMPORT)
+                && candidates.tokens.containsKey(DRL10Parser.DRL_RULE);
+    }
+
     /**
      * Completion items for the fields of the pattern enclosing the caret:
      * fields of a DRL-declared type (current document first, then sibling
@@ -208,6 +250,165 @@ public class DRLCompletionHelper {
         return fieldItems(memberIndex.membersOf(fqcn));
     }
 
+    /**
+     * The dot-separated segments preceding a caret that sits immediately after a
+     * dot ({@code $ref.order.|} → {@code [$ref, order]}), or {@code null} when the
+     * caret follows something else. A numeric head is a decimal literal being
+     * typed, not a path.
+     */
+    private static String[] dottedChainBeforeCaret(String text, Position caret) {
+        if (text == null || caret == null) {
+            return null;
+        }
+        String[] lines = text.split("\r?\n", -1);
+        int row = caret.getLine();
+        if (row < 0 || row >= lines.length) {
+            return null;
+        }
+        String line = lines[row];
+        int col = Math.min(caret.getCharacter(), line.length());
+        if (col == 0 || line.charAt(col - 1) != '.') {
+            return null;
+        }
+        int start = col - 1;
+        while (start > 0 && isChainChar(line.charAt(start - 1))) {
+            start--;
+        }
+        String path = line.substring(start, col - 1);
+        if (path.isEmpty() || path.endsWith(".") || Character.isDigit(path.charAt(0))) {
+            return null;
+        }
+        return path.split("\\.", -1);
+    }
+
+    private static boolean isChainChar(char c) {
+        return Character.isLetterOrDigit(c) || c == '_' || c == '$' || c == '.';
+    }
+
+    /**
+     * Member items for a dotted path the caret sits at the end of. The head is a
+     * binding ({@code $p.}), a field of the enclosing pattern's type ({@code ref.}
+     * inside {@code Fact(...)}), or a type name ({@code Status.}); the remaining
+     * segments are fields. Every hop goes through the same walker the bindings and
+     * hover use, so all three agree on what a path resolves to.
+     */
+    /**
+     * Completion items for the members of the type the chain resolves to, or
+     * {@code null} when the chain's head names no type the document knows — a
+     * qualified name rather than a member access. An empty list means the path
+     * did resolve and simply has no members to offer, which is an answer: after
+     * a dot nothing but a member is legal.
+     */
+    private static List<CompletionItem> memberItemsForChain(String[] chain, String text, Position caret,
+                                                            DRL10Parser.CompilationUnitContext compilationUnit,
+                                                            int caretTokenIndex, ClassIndex classIndex,
+                                                            ClassMemberIndex memberIndex, Path documentPath,
+                                                            Map<Path, String> openFiles) {
+        if (compilationUnit == null) {
+            return null;
+        }
+        Map<String, DeclaredType> typeIndex = DRLWorkspaceTypeIndex.build(
+                DRLDeclaredTypeParser.extractFromCompilationUnit(compilationUnit), documentPath, openFiles);
+
+        String head = chain[0];
+        String rootType;
+        int firstFieldSegment = 1;
+        if (head.startsWith("$")) {
+            rootType = LhsBindingResolver.resolveAt(text, DRLHoverHelper.positionToOffset(text, caret), typeIndex)
+                    .get(head.substring(1));
+        } else if (!head.isEmpty() && Character.isUpperCase(head.charAt(0))) {
+            rootType = head;
+        } else {
+            // A bare lower-case head is a field of the pattern the caret is in.
+            rootType = enclosingPatternTypeFromText(text, DRLHoverHelper.positionToOffset(text, caret));
+            firstFieldSegment = 0;
+        }
+        if (rootType == null || rootType.isEmpty()) {
+            // The head names no type the document knows, so this dot is not a
+            // member access at all — a qualified name, most likely.
+            return null;
+        }
+
+        // Kept qualified. A pattern head written as a fully-qualified name is
+        // how an author disambiguates a simple name that collides on the
+        // classpath, and resolveFqcn returns a dotted name as-is, so dropping
+        // the package here is what leaves it unresolvable without an import.
+        // Both consumers below simplify for themselves where they need to.
+        String resolved = rootType;
+        if (firstFieldSegment < chain.length) {
+            String path = String.join(".", Arrays.copyOfRange(chain, firstFieldSegment, chain.length));
+            resolved = LhsBindingResolver.resolvePath(
+                    LhsBindingResolver.typeOrClasspath(resolved, typeIndex), path, typeIndex);
+            if (resolved == null) {
+                return List.of();
+            }
+        }
+        return memberItemsOfType(resolved, typeIndex, compilationUnit, classIndex, memberIndex);
+    }
+
+    /**
+     * The type name of the pattern whose parentheses are still open at
+     * {@code offset}, read from the text rather than the parse tree: a caret in
+     * mid-edit leaves the constraint incomplete, and error recovery then parses
+     * the incomplete path itself as a pattern head, which
+     * {@link #findEnclosingPatternTypeName} would return in preference to the
+     * real pattern. Non-pattern parens ({@code accumulate(}, {@code eval(}, a
+     * method call) are stepped over — a DRL pattern type's simple name always
+     * begins upper case, which is also how {@code LhsBindingResolver} finds
+     * pattern heads. {@code null} when no pattern encloses the offset.
+     */
+    private static String enclosingPatternTypeFromText(String text, int offset) {
+        // Scanned over the masked text: a parenthesis or a terminator inside a
+        // string literal or a comment would otherwise unbalance the count and
+        // find the wrong pattern head, or none. The mask keeps every offset, so
+        // the indices below still address the original.
+        String scan = LhsBindingResolver.maskCommentsAndStrings(text);
+        int closed = 0;
+        for (int i = offset - 1; i >= 0; i--) {
+            char c = scan.charAt(i);
+            if (c == ')') {
+                closed++;
+            } else if (c == ';' || c == '}') {
+                return null;
+            } else if (c == '(') {
+                if (closed > 0) {
+                    closed--;
+                    continue;
+                }
+                int end = i;
+                while (end > 0 && Character.isWhitespace(scan.charAt(end - 1))) {
+                    end--;
+                }
+                int start = end;
+                while (start > 0 && isChainChar(scan.charAt(start - 1))) {
+                    start--;
+                }
+                String word = text.substring(start, end);
+                String simple = word.substring(word.lastIndexOf('.') + 1);
+                if (!simple.isEmpty() && Character.isUpperCase(simple.charAt(0))) {
+                    return word;
+                }
+            }
+        }
+        return null;
+    }
+
+    /** Field items for a type name: DRL declares win, then the classpath. */
+    private static List<CompletionItem> memberItemsOfType(String typeName, Map<String, DeclaredType> typeIndex,
+                                                          DRL10Parser.CompilationUnitContext compilationUnit,
+                                                          ClassIndex classIndex, ClassMemberIndex memberIndex) {
+        String simple = typeName.substring(typeName.lastIndexOf('.') + 1);
+        DeclaredType declared = typeIndex.get(simple);
+        if (declared != null) {
+            return fieldItems(DRLDeclaredTypeParser.fieldsIncludingInherited(declared, typeIndex));
+        }
+        String fqcn = resolveFqcn(typeName, simple, compilationUnit, classIndex);
+        if (fqcn == null) {
+            return List.of();
+        }
+        return fieldItems(memberIndex.membersOf(fqcn));
+    }
+
     private static List<CompletionItem> fieldItems(List<Field> fields) {
         List<CompletionItem> items = new ArrayList<>(fields.size());
         for (Field field : fields) {
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDeclaredTypeParser.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDeclaredTypeParser.java
index 483f0f10c2d..1506c92827a 100644
--- a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDeclaredTypeParser.java
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDeclaredTypeParser.java
@@ -124,6 +124,9 @@ public final class DRLDeclaredTypeParser {
         while (parentName != null && depth++ < 10 && seen.add(parentName)) {
             DeclaredType parent = index.get(parentName);
             if (parent == null) {
+                // A declare may extend a Java class, whose members only the host
+                // can supply; they arrive already flattened, so the walk ends here.
+                out.addAll(ClasspathTypeMembers.membersOf(parentName));
                 break;
             }
             out.addAll(parent.fields);
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDefinitionHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDefinitionHelper.java
index 12552be3ec3..be32b12fcd9 100644
--- a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDefinitionHelper.java
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDefinitionHelper.java
@@ -44,6 +44,9 @@ import org.eclipse.lsp4j.Range;
  *       classpath contains {@code <module>/target/classes/pkg/Type.class},
  *       the definition is {@code <module>/src/main/java/pkg/Type.java} if
  *       that file exists. JAR classes have no navigable source.</li>
+ *   <li>when no compiled {@code .class} exists yet (pre-build), the {@code
+ *       .java} source location from {@code sourceIndex}, if the type was
+ *       parsed from workspace source.</li>
  * </ol>
  */
 public final class DRLDefinitionHelper {
@@ -61,10 +64,14 @@ public final class DRLDefinitionHelper {
      *
      * @param buildOutputDirs build-output directories from the resolved
      *                        classpath, used for the Maven source-mapping
+     * @param sourceIndex     source-parsed types, consulted when
+     *                        {@code buildOutputDirs} has no compiled {@code
+     *                        .class} for the type yet
      */
     public static List<Location> findDefinitions(String uri, String text, Position position,
-                                                 ClassIndex classIndex, Set<Path> buildOutputDirs) {
-        return findDefinitions(uri, text, position, classIndex, buildOutputDirs, Map.of());
+                                                 ClassIndex classIndex, Set<Path> buildOutputDirs,
+                                                 JavaSourceTypeIndex sourceIndex) {
+        return findDefinitions(uri, text, position, classIndex, buildOutputDirs, sourceIndex, Map.of());
     }
 
     /**
@@ -74,7 +81,7 @@ public final class DRLDefinitionHelper {
      */
     public static List<Location> findDefinitions(String uri, String text, Position position,
                                                  ClassIndex classIndex, Set<Path> buildOutputDirs,
-                                                 Map<Path, String> openFiles) {
+                                                 JavaSourceTypeIndex sourceIndex, Map<Path, String> openFiles) {
         if (text == null || position == null) {
             return List.of();
         }
@@ -107,7 +114,27 @@ public final class DRLDefinitionHelper {
             return List.of();
         }
         JavaSourceLocator.Result javaSource = JavaSourceLocator.locate(fqcn, buildOutputDirs);
-        return javaSource == null ? List.of() : List.of(javaSource.location);
+        if (javaSource != null) {
+            return List.of(javaSource.location);
+        }
+        Location fromSource = sourceLocation(sourceIndex, fqcn);
+        return fromSource == null ? List.of() : List.of(fromSource);
+    }
+
+    /**
+     * Builds a {@link Location} anchored at {@code fqcn}'s declaration-name
+     * token from {@code sourceIndex}, or {@code null} when the index doesn't
+     * know {@code fqcn} (unparsed, or not under any indexed source root).
+     */
+    private static Location sourceLocation(JavaSourceTypeIndex sourceIndex, String fqcn) {
+        Path file = sourceIndex.fileOf(fqcn);
+        JavaSourceType type = sourceIndex.byFqcn(fqcn);
+        if (file == null || type == null) {
+            return null;
+        }
+        Range range = new Range(new Position(type.declLine, type.declColumn),
+                                new Position(type.declLine, type.declColumn + type.simpleName.length()));
+        return new Location(file.toUri().toString(), range);
     }
 
     /**
@@ -214,7 +241,13 @@ public final class DRLDefinitionHelper {
         return new Range(new Position(position.getLine(), start), new Position(position.getLine(), end));
     }
 
-    private static boolean isIdentifierChar(char c) {
+    /**
+     * The identifier alphabet shared by the word/chain extractors: DRL
+     * identifiers plus the {@code $} binding prefix. Package-private so
+     * {@link DRLHoverHelper}'s chain extraction stays character-for-character
+     * consistent with {@link #wordAt}.
+     */
+    static boolean isIdentifierChar(char c) {
         return Character.isLetterOrDigit(c) || c == '_' || c == '$';
     }
 
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLHoverHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLHoverHelper.java
index 6136119804a..2a7e6bfb18a 100644
--- a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLHoverHelper.java
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLHoverHelper.java
@@ -23,6 +23,8 @@ import java.nio.file.Path;
 import java.util.List;
 import java.util.Map;
 
+import org.antlr.v4.runtime.tree.ParseTree;
+import org.antlr.v4.runtime.tree.TerminalNode;
 import org.drools.drl.parser.antlr4.DRL10Parser;
 import org.eclipse.lsp4j.Hover;
 import org.eclipse.lsp4j.MarkupContent;
@@ -30,14 +32,19 @@ import org.eclipse.lsp4j.MarkupKind;
 import org.eclipse.lsp4j.Position;
 
 /**
- * Hover content for DRL documents: type structure for pattern type names and
- * type information for fields inside constraints.
+ * Hover content for DRL documents: declared and classpath types, fields inside
+ * constraints, bound variables, dotted member chains ({@code $o.total},
+ * {@code Status.ACTIVE}) rendered at the hovered segment, qualified enum
+ * constants, accumulate-function names, and doc comments for documented
+ * {@code function}/{@code query}/{@code global} declarations.
  *
  * <p>Type names resolve through {@link DRLWorkspaceTypeIndex} — the same
  * layered view (current document, open unsaved siblings, on-disk siblings)
  * that completion and go-to-definition use — then classpath types through
  * imports and the class index. Declared types render as their declare block
  * with the doc comment above it, classpath types as their member list.
+ * Accumulate functions are detected structurally from the parse tree; their
+ * result types come from {@link AccumulateFunctionTypes}.
  */
 public final class DRLHoverHelper {
 
@@ -75,12 +82,21 @@ public final class DRLHoverHelper {
             return null;
         }
 
+        // Dotted member chain ($o.total, Status.ACTIVE, $a.b.c): handled by a
+        // dedicated segment walk. Single-segment words take the steps below.
+        Chain chain = chainAt(text, position);
+
         // Parse the current document once; every step below reuses this parse.
         ParsedDrl parsed = ParsedDrl.of(text);
         List<DeclaredType> currentDocTypes = parsed.declaredTypes();
         Map<String, DeclaredType> typeIndex =
                 DRLWorkspaceTypeIndex.build(currentDocTypes, documentPath, openFiles);
 
+        if (chain.segments.length >= 2) {
+            return hoverChain(chain, text, position, parsed, currentDocTypes, typeIndex,
+                              classIndex, memberIndex, documentPath, openFiles);
+        }
+
         // 1. The word is itself a DRL-declared type.
         DeclaredType declared = typeIndex.get(word);
         if (declared != null) {
@@ -91,7 +107,19 @@ public final class DRLHoverHelper {
         DRL10Parser.CompilationUnitContext compilationUnit = parsed.compilationUnit;
         Integer nodeIndex = parsed.tokenIndexAt(position);
 
-        // 2. Bound variable: resolve $var to its type via the shared binding
+        // 1b. Accumulate function name ($c : count()): detected purely from
+        //     the parse tree — the caret's terminal sits under the function's
+        //     own identifier. Structural, so it outranks the name-based doc
+        //     step below: at this position the identifier can only be an
+        //     accumulate function, even when a DRL function shares its name.
+        if (nodeIndex != null) {
+            Hover accumulate = accumulateFunctionHover(word, compilationUnit, nodeIndex);
+            if (accumulate != null) {
+                return accumulate;
+            }
+        }
+
+        // 3. Bound variable: resolve $var to its type via the shared binding
         //    engine (pattern, field, nested-path, JDK-accessor, accumulate),
         //    scoped to the rule under the caret so a binding name reused across
         //    rules resolves to the right one, then hover that type.
@@ -108,12 +136,16 @@ public final class DRLHoverHelper {
                 String boundFqcn = DRLCompletionHelper.resolveFqcn(
                         boundType, boundType, compilationUnit, classIndex);
                 if (boundFqcn != null) {
-                    return markdown(renderJavaType(boundType, boundFqcn, memberIndex.membersOf(boundFqcn)));
+                    return markdown(renderJavaType(boundType, boundFqcn, memberIndex.membersOf(boundFqcn),
+                            memberIndex.constructorsOf(boundFqcn)));
                 }
+                // Nothing to describe beyond the name: a primitive has no class
+                // to load and no members, and the type is the useful part anyway.
+                return markdown(fencedHeader(word + " : " + boundType).stripTrailing());
             }
         }
 
-        // 3. Field of the pattern enclosing the caret.
+        // 4. Field of the pattern enclosing the caret.
         if (nodeIndex != null) {
             String patternType = DRLCompletionHelper.findEnclosingPatternTypeName(
                     compilationUnit, nodeIndex);
@@ -121,22 +153,363 @@ public final class DRLHoverHelper {
                 Field field = findField(patternType, word, typeIndex,
                                         compilationUnit, classIndex, memberIndex);
                 if (field != null) {
-                    String owner = patternType.substring(patternType.lastIndexOf('.') + 1);
-                    return markdown("**" + field.name + "** : `" + field.type
-                            + "`\n\nField of `" + owner + "`");
+                    return markdown(renderField(field, simpleName(patternType)));
                 }
             }
         }
 
-        // 4. Classpath type (or java.lang built-in). Show the hover even with no
+        // 5. Classpath type (or java.lang built-in). Show the hover even with no
         //    members — knowing the FQN (e.g. java.lang.Object) is still useful.
         String fqcn = DRLCompletionHelper.resolveFqcn(word, word, compilationUnit, classIndex);
         if (fqcn != null) {
-            return markdown(renderJavaType(word, fqcn, memberIndex.membersOf(fqcn)));
+            return markdown(renderJavaType(word, fqcn, memberIndex.membersOf(fqcn),
+                    memberIndex.constructorsOf(fqcn)));
+        }
+
+        // 6. Documented function/query/global, last of all. The doc-comment
+        //    parser maps names across the whole document with no position
+        //    scoping, so anything the position itself identifies — a binding, a
+        //    field, a type — describes itself first, and a doc comment answers
+        //    only for a name nothing else claims.
+        return docHover(word, currentDocTypes, text, documentPath, openFiles);
+    }
+
+    /**
+     * The dotted member chain around the caret ({@code $o.total},
+     * {@code Status.ACTIVE.level}): the dot-separated segments (an empty entry
+     * marks a stray dot, e.g. {@code a..b} mid-edit) and the index of the
+     * segment the caret sits in.
+     */
+    private static final class Chain {
+
+        final String[] segments;
+        final int hoveredIndex;
+
+        Chain(String[] segments, int hoveredIndex) {
+            this.segments = segments;
+            this.hoveredIndex = hoveredIndex;
+        }
+    }
+
+    /**
+     * Extracts the dotted chain around the caret by expanding over identifier
+     * characters and {@code .} on the caret's line. Only called after the word
+     * guard in {@link #hover}, so the line index is valid and at least one
+     * segment is non-empty. Kept separate from
+     * {@link DRLDefinitionHelper#wordAt}, whose single-identifier expansion is
+     * shared with rename and go-to-definition.
+     */
+    private static Chain chainAt(String text, Position position) {
+        String[] lines = text.split("\r?\n", -1);
+        String line = lines[position.getLine()];
+        int col = Math.min(Math.max(position.getCharacter(), 0), line.length());
+
+        int start = col;
+        while (start > 0 && isChainChar(line.charAt(start - 1))) {
+            start--;
+        }
+        int end = col;
+        while (end < line.length() && isChainChar(line.charAt(end))) {
+            end++;
+        }
+        String[] segments = line.substring(start, end).split("\\.", -1);
+        return new Chain(segments, hoveredSegment(segments, col - start));
+    }
+
+    private static boolean isChainChar(char c) {
+        return DRLDefinitionHelper.isIdentifierChar(c) || c == '.';
+    }
+
+    /** Index of the segment containing the caret, {@code rel} chars into the chain. */
+    private static int hoveredSegment(String[] segments, int rel) {
+        int segmentStart = 0;
+        for (int i = 0; i < segments.length; i++) {
+            int segmentEnd = segmentStart + segments[i].length();
+            if (rel <= segmentEnd) {
+                return i;
+            }
+            segmentStart = segmentEnd + 1; // skip the '.'
+        }
+        return segments.length - 1;
+    }
+
+    /**
+     * Resolves a dotted chain segment by segment up to the hovered one,
+     * tracking the running type, and renders only the hovered segment.
+     *
+     * <p>Segment 0 resolves as a {@code $binding} (rule-scoped), a declared
+     * type, a classpath type, or a field of the enclosing pattern — and, when
+     * it is itself the hovered segment, falls back to a documented
+     * function/query/global by that name (the chain head is a plain identifier
+     * reference, e.g. {@code results} in {@code results.add(..)}). Later
+     * segments are enum constants or fields of the running type; a constant is
+     * an instance of its enum, so member access on it continues from the enum
+     * type itself.
+     *
+     * <p>Any segment before the hovered one that does not resolve yields no
+     * hover at all: falling back to the single-word steps with one segment of
+     * the chain would mis-render it (e.g. a chain tail as a field of the
+     * enclosing pattern).
+     */
+    private static Hover hoverChain(Chain chain, String text, Position position,
+                                    ParsedDrl parsed, List<DeclaredType> currentDocTypes,
+                                    Map<String, DeclaredType> typeIndex,
+                                    ClassIndex classIndex, ClassMemberIndex memberIndex,
+                                    Path documentPath, Map<Path, String> openFiles) {
+        String runningType = null;
+        int start = 0;
+
+        // A chain can open with a fully-qualified type name, whose leading
+        // segments are package names that resolve to nothing on their own
+        // (`com` in `com.example.model.Order`). Consume the longest dotted
+        // prefix that names a known type first, so the walk below starts from
+        // the type rather than failing on the package.
+        int fqcnEnd = fqcnPrefixEnd(chain.segments, classIndex);
+        if (fqcnEnd >= 1) {
+            String fqcn = joinSegments(chain.segments, fqcnEnd);
+            if (chain.hoveredIndex <= fqcnEnd) {
+                // Anywhere inside the qualified name describes that type.
+                String simple = chain.segments[fqcnEnd];
+                return markdown(renderJavaType(simple, fqcn, memberIndex.membersOf(fqcn),
+                        memberIndex.constructorsOf(fqcn)));
+            }
+            runningType = fqcn;
+            start = fqcnEnd + 1;
+        }
+
+        for (int i = start; i <= chain.hoveredIndex; i++) {
+            String segment = chain.segments[i];
+            boolean hovered = i == chain.hoveredIndex;
+            if (segment.isEmpty()) {
+                return null;
+            }
+
+            if (i == 0) {
+                if (segment.charAt(0) == '$') {
+                    int offset = positionToOffset(text, position);
+                    String boundType = LhsBindingResolver.resolveAt(text, offset, typeIndex)
+                            .get(segment.substring(1));
+                    if (boundType == null) {
+                        return null;
+                    }
+                    if (hovered) {
+                        return markdown(renderBinding(segment, boundType, parsed, typeIndex,
+                                currentDocTypes, text, classIndex, memberIndex,
+                                documentPath, openFiles));
+                    }
+                    runningType = boundType;
+                } else if (typeIndex.get(segment) != null) {
+                    if (hovered) {
+                        return markdown(renderDeclaredHover(typeIndex.get(segment), typeIndex,
+                                currentDocTypes, text, documentPath, openFiles));
+                    }
+                    runningType = segment;
+                } else {
+                    String fqcn = DRLCompletionHelper.resolveFqcn(
+                            segment, segment, parsed.compilationUnit, classIndex);
+                    if (fqcn != null) {
+                        if (hovered) {
+                            return markdown(renderJavaType(segment, fqcn, memberIndex.membersOf(fqcn),
+                                    memberIndex.constructorsOf(fqcn)));
+                        }
+                        runningType = fqcn;
+                    } else {
+                        Integer nodeIndex = parsed.tokenIndexAt(position);
+                        String patternType = nodeIndex == null ? null
+                                : DRLCompletionHelper.findEnclosingPatternTypeName(
+                                        parsed.compilationUnit, nodeIndex);
+                        Field field = patternType == null || patternType.equals(segment) ? null
+                                : findField(patternType, segment, typeIndex,
+                                            parsed.compilationUnit, classIndex, memberIndex);
+                        if (field != null) {
+                            if (hovered) {
+                                return markdown(renderField(field, simpleName(patternType)));
+                            }
+                            runningType = field.type;
+                        } else if (hovered) {
+                            return docHover(segment, currentDocTypes, text,
+                                            documentPath, openFiles);
+                        } else {
+                            return null;
+                        }
+                    }
+                }
+            } else {
+                DeclaredType declared = typeIndex.get(simpleName(runningType));
+                if (declared != null && declared.isEnum && isEnumConstant(declared, segment)) {
+                    if (hovered) {
+                        return markdown(fencedHeader(declared.name + "." + segment)
+                                + renderDeclaredHover(declared, typeIndex, currentDocTypes,
+                                                      text, documentPath, openFiles));
+                    }
+                    // Running type stays the enum: the constant is an instance of it.
+                } else {
+                    Field field = findField(runningType, segment, typeIndex,
+                                            parsed.compilationUnit, classIndex, memberIndex);
+                    if (field == null) {
+                        return null;
+                    }
+                    if (hovered) {
+                        return markdown(renderField(field, simpleName(runningType)));
+                    }
+                    runningType = field.type;
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * The index of the last segment of the longest leading run of
+     * {@code segments} that names a type on the classpath, or {@code -1} when
+     * no prefix of two or more segments does. Longest-first so
+     * {@code com.example.Order.status} prefers the type {@code com.example.Order}
+     * over a shorter accidental match.
+     */
+    private static int fqcnPrefixEnd(String[] segments, ClassIndex classIndex) {
+        for (int end = segments.length - 1; end >= 1; end--) {
+            String simple = segments[end];
+            if (simple.isEmpty()) {
+                continue;
+            }
+            String candidate = joinSegments(segments, end);
+            if (classIndex.forSimpleName(simple).contains(candidate)) {
+                return end;
+            }
+        }
+        return -1;
+    }
+
+    /** {@code segments[0..end]} rejoined with dots. */
+    private static String joinSegments(String[] segments, int end) {
+        StringBuilder sb = new StringBuilder(segments[0]);
+        for (int i = 1; i <= end; i++) {
+            sb.append('.').append(segments[i]);
+        }
+        return sb.toString();
+    }
+
+    /** True when {@code name} is a constant of {@code enumType} (a field typed as the enum). */
+    private static boolean isEnumConstant(DeclaredType enumType, String name) {
+        return enumType.fields.stream()
+                .anyMatch(field -> name.equals(field.name) && enumType.name.equals(field.type));
+    }
+
+    /** Header {@code $x : Type} followed by the bound type's details. */
+    private static String renderBinding(String binding, String typeName, ParsedDrl parsed,
+                                        Map<String, DeclaredType> typeIndex,
+                                        List<DeclaredType> currentDocTypes, String text,
+                                        ClassIndex classIndex, ClassMemberIndex memberIndex,
+                                        Path documentPath, Map<Path, String> openFiles) {
+        String header = fencedHeader(binding + " : " + typeName);
+        DeclaredType declared = typeIndex.get(typeName);
+        if (declared != null) {
+            return header + renderDeclaredHover(declared, typeIndex, currentDocTypes, text,
+                                                documentPath, openFiles);
+        }
+        String fqcn = DRLCompletionHelper.resolveFqcn(
+                typeName, typeName, parsed.compilationUnit, classIndex);
+        if (fqcn != null) {
+            return header + renderJavaType(typeName, fqcn, memberIndex.membersOf(fqcn),
+                    memberIndex.constructorsOf(fqcn));
+        }
+        return header.stripTrailing();
+    }
+
+    /**
+     * Hover for an accumulate function name ({@code count} in
+     * {@code $c : count()}), or {@code null} when the word at
+     * {@code tokenIndex} is not one. Detection is purely structural: the
+     * terminal at that token index must BE (part of) the function identifier
+     * of an {@link DRL10Parser.AccumulateFunctionContext} — an argument whose
+     * text merely equals the function name does not qualify — and the name
+     * must be a known accumulate function.
+     */
+    private static Hover accumulateFunctionHover(String word,
+                                                 DRL10Parser.CompilationUnitContext compilationUnit,
+                                                 int tokenIndex) {
+        TerminalNode terminal = terminalAtTokenIndex(compilationUnit, tokenIndex);
+        if (terminal == null) {
+            return null;
+        }
+        DRL10Parser.AccumulateFunctionContext function = enclosingAccumulateFunction(terminal);
+        if (function == null || function.drlIdentifier() == null
+                || !word.equals(function.drlIdentifier().getText())
+                || !isUnder(terminal, function.drlIdentifier())) {
+            return null;
+        }
+        String resultType = AccumulateFunctionTypes.get().get(word);
+        if (resultType == null) {
+            return null;
+        }
+        return markdown(fencedHeader(word + " : " + resultType) + "_accumulate function_");
+    }
+
+    /**
+     * The terminal node whose token sits at {@code tokenIndex} in the parse
+     * tree rooted at {@code node}, or {@code null} when none does.
+     */
+    private static TerminalNode terminalAtTokenIndex(ParseTree node, int tokenIndex) {
+        if (node instanceof TerminalNode terminal) {
+            return terminal.getSymbol().getTokenIndex() == tokenIndex ? terminal : null;
+        }
+        for (int i = 0; i < node.getChildCount(); i++) {
+            TerminalNode found = terminalAtTokenIndex(node.getChild(i), tokenIndex);
+            if (found != null) {
+                return found;
+            }
         }
         return null;
     }
 
+    /** The nearest {@code accumulateFunction} ancestor of {@code node}, or {@code null}. */
+    private static DRL10Parser.AccumulateFunctionContext enclosingAccumulateFunction(ParseTree node) {
+        for (ParseTree current = node; current != null; current = current.getParent()) {
+            if (current instanceof DRL10Parser.AccumulateFunctionContext function) {
+                return function;
+            }
+        }
+        return null;
+    }
+
+    /** True when {@code node} is {@code ancestor} itself or sits beneath it. */
+    private static boolean isUnder(ParseTree node, ParseTree ancestor) {
+        for (ParseTree current = node; current != null; current = current.getParent()) {
+            if (current == ancestor) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static String fencedHeader(String content) {
+        return "```\n" + content + "\n```\n\n";
+    }
+
+    private static String renderField(Field field, String owner) {
+        return "**" + field.name + "** : `" + field.type + "`\n\nField of `" + owner + "`";
+    }
+
+    private static String simpleName(String typeName) {
+        return typeName.substring(typeName.lastIndexOf('.') + 1);
+    }
+
+    /**
+     * Hover for a documented function, query, or global named {@code name}, or
+     * {@code null} when it has no doc comment. Shared by the single-word flow
+     * and the chain-head fallback.
+     */
+    private static Hover docHover(String name, List<DeclaredType> currentDocTypes, String text,
+                                  Path documentPath, Map<Path, String> openFiles) {
+        String doc = DRLDocCommentParser.docFor(text, name);
+        if (doc == null) {
+            return null;
+        }
+        Map<String, String> linkTargets = DRLWorkspaceTypeIndex.buildLinkTargets(
+                currentDocTypes, text, documentPath, openFiles);
+        return markdown(DRLDocFormatter.format(doc, linkTargets));
+    }
+
     /**
      * Renders the full declared-type hover: doc comment (with {@code {@link}}
      * references resolved to the declarations they name), the declare block,
@@ -221,15 +594,45 @@ public final class DRLHoverHelper {
         return sb.toString();
     }
 
-    private static String renderJavaType(String simpleName, String fqcn, List<Field> members) {
+    private static String renderJavaType(String simpleName, String fqcn, List<Field> members,
+                                         List<String> constructors) {
         StringBuilder sb = new StringBuilder();
         sb.append("**").append(simpleName).append("** — `").append(fqcn).append("`\n");
-        for (Field member : members) {
-            sb.append("\n- ").append(member.name).append(" : ").append(member.type);
+        appendMemberSection(sb, "Constants", members, Field.Origin.ENUM_CONSTANT);
+        appendMemberSection(sb, "Fields", members, Field.Origin.FIELD);
+        appendMemberSection(sb, "Getters", members, Field.Origin.GETTER);
+        if (!constructors.isEmpty()) {
+            sb.append("\n\n**Constructors**");
+            for (String signature : constructors) {
+                sb.append("\n- `").append(signature).append('`');
+            }
         }
         return sb.toString();
     }
 
+    /** Appends {@code title}'s section for {@code members} of the given {@code origin}, or nothing when none match. */
+    private static void appendMemberSection(StringBuilder sb, String title, List<Field> members,
+                                            Field.Origin origin) {
+        boolean headerWritten = false;
+        for (Field member : members) {
+            if (member.origin != origin) {
+                continue;
+            }
+            if (!headerWritten) {
+                sb.append("\n\n**").append(title).append("**");
+                headerWritten = true;
+            }
+            if (origin == Field.Origin.ENUM_CONSTANT) {
+                sb.append("\n- ").append(member.name);
+                if (member.args != null) {
+                    sb.append(" (").append(member.args).append(')');
+                }
+            } else {
+                sb.append("\n- ").append(member.name).append(" : ").append(member.type);
+            }
+        }
+    }
+
     private static Hover markdown(String content) {
         return new Hover(new MarkupContent(MarkupKind.MARKDOWN, content));
     }
@@ -239,7 +642,7 @@ public final class DRLHoverHelper {
      * character offset into {@code text}. DRL files are ASCII in practice, so
      * the character offset matches code units. Clamped to {@code text.length()}.
      */
-    private static int positionToOffset(String text, Position position) {
+    static int positionToOffset(String text, Position position) {
         int line = position.getLine();
         int offset = 0;
         int currentLine = 0;
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLInlayHintHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLInlayHintHelper.java
index f417a10bd5d..30d7832236f 100644
--- a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLInlayHintHelper.java
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLInlayHintHelper.java
@@ -104,7 +104,12 @@ public final class DRLInlayHintHelper {
         Map<String, String> accum = AccumulateFunctionTypes.get();
         List<InlayHint> hints = new ArrayList<>();
 
-        Matcher ruleMatch = RULE_BLOCK.matcher(text);
+        // Scanned with comments and string literals blanked out, so a "then"
+        // inside either does not end a rule's condition early. The mask keeps
+        // the text's length, so every offset below still indexes into text
+        // itself; it also stops a $name inside a comment or literal being
+        // mistaken for a usage worth annotating.
+        Matcher ruleMatch = RULE_BLOCK.matcher(LhsBindingResolver.maskCommentsAndStrings(text));
         while (ruleMatch.find()) {
             String whenSection = ruleMatch.group(1);
             String thenSection = ruleMatch.group(2);
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLTypeHierarchyHelper.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLTypeHierarchyHelper.java
index 4dc0797f477..6d6f2aa37fe 100644
--- a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLTypeHierarchyHelper.java
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLTypeHierarchyHelper.java
@@ -39,8 +39,11 @@ import org.eclipse.lsp4j.TypeHierarchyItem;
  * <ul>
  *   <li><b>Prepare</b> resolves the identifier at the caret to its declaration:
  *       a DRL {@code declare} (current doc or sibling) → its {@code .drl}
- *       declare-site; or a classpath type → its project {@code .java} source
- *       (navigable only — JAR-only types resolve to nothing).</li>
+ *       declare-site; or a classpath type → its project {@code .java} source,
+ *       compiled-first, falling back to a source-parsed location (pre-build)
+ *       when the type has no compiled {@code .class} yet — see
+ *       {@link JavaSourceTypeIndex} (navigable only — JAR-only types with no
+ *       project source resolve to nothing).</li>
  *   <li><b>Supertypes</b>: a declare's {@code extends} parent (declare → {@code .drl},
  *       Java parent → reflected ancestry, navigable-only); a classpath type's
  *       direct superclass + interfaces via {@link ClassMemberIndex#supertypesOf}.</li>
@@ -78,7 +81,7 @@ public final class DRLTypeHierarchyHelper {
      */
     public static List<TypeHierarchyItem> prepare(String text, Position position, String uri,
                                                   Map<Path, String> openFiles, ClassIndex classIndex,
-                                                  Set<Path> buildOutputDirs) {
+                                                  Set<Path> buildOutputDirs, JavaSourceTypeIndex sourceIndex) {
         try {
             if (text == null || position == null) {
                 return Collections.emptyList();
@@ -92,7 +95,7 @@ public final class DRLTypeHierarchyHelper {
                 return Collections.singletonList(declareItem(declared));
             }
             TypeHierarchyItem classpath = classpathItem(
-                    DRLDefinitionHelper.resolveFqcn(text, word, classIndex), buildOutputDirs);
+                    DRLDefinitionHelper.resolveFqcn(text, word, classIndex), buildOutputDirs, sourceIndex);
             return classpath == null ? Collections.emptyList() : Collections.singletonList(classpath);
         } catch (Exception e) {
             logger.fine(() -> "prepareTypeHierarchy failed: " + e.getMessage());
@@ -106,14 +109,15 @@ public final class DRLTypeHierarchyHelper {
      */
     public static List<TypeHierarchyItem> supertypes(TypeHierarchyItem item, String declaringText,
                                                      Map<Path, String> openFiles, ClassIndex classIndex,
-                                                     ClassMemberIndex memberIndex, Set<Path> buildOutputDirs) {
+                                                     ClassMemberIndex memberIndex, Set<Path> buildOutputDirs,
+                                                     JavaSourceTypeIndex sourceIndex) {
         try {
             String fqcn = fqcnData(item);
             if (fqcn != null) {
                 // Classpath type: direct superclass + interfaces, navigable-only.
                 List<TypeHierarchyItem> out = new ArrayList<>();
                 for (String parent : memberIndex.supertypesOf(fqcn)) {
-                    TypeHierarchyItem parentItem = classpathItem(parent, buildOutputDirs);
+                    TypeHierarchyItem parentItem = classpathItem(parent, buildOutputDirs, sourceIndex);
                     if (parentItem != null) {
                         out.add(parentItem);
                     }
@@ -130,7 +134,8 @@ public final class DRLTypeHierarchyHelper {
                 return Collections.singletonList(declareItem(parentDeclare));
             }
             TypeHierarchyItem classpathParent = classpathItem(
-                    DRLDefinitionHelper.resolveFqcn(declaringText, self.extendsName, classIndex), buildOutputDirs);
+                    DRLDefinitionHelper.resolveFqcn(declaringText, self.extendsName, classIndex),
+                    buildOutputDirs, sourceIndex);
             return classpathParent == null ? Collections.emptyList()
                     : Collections.singletonList(classpathParent);
         } catch (Exception e) {
@@ -217,17 +222,40 @@ public final class DRLTypeHierarchyHelper {
         return item;  // data left null → re-resolved from uri + name
     }
 
-    /** Builds a navigable classpath item, or {@code null} when no project source resolves. */
-    private static TypeHierarchyItem classpathItem(String fqcn, Set<Path> buildOutputDirs) {
+    /**
+     * Builds a navigable classpath item: a compiled {@code .class}'s project
+     * source first, else — when no {@code .class} exists yet (pre-build) —
+     * {@code sourceIndex}'s parsed location; {@code null} when neither
+     * resolves.
+     */
+    private static TypeHierarchyItem classpathItem(String fqcn, Set<Path> buildOutputDirs,
+                                                    JavaSourceTypeIndex sourceIndex) {
         if (fqcn == null) {
             return null;
         }
         JavaSourceLocator.Result res = JavaSourceLocator.locate(fqcn, buildOutputDirs);
-        if (res == null) {
+        if (res != null) {
+            TypeHierarchyItem item = new TypeHierarchyItem(simpleName(fqcn), res.kind,
+                    res.location.getUri(), res.location.getRange(), res.location.getRange());
+            item.setData(fqcn);
+            item.setDetail(fqcn);
+            return item;
+        }
+        return sourceItem(fqcn, sourceIndex);
+    }
+
+    /** As the source-index branch of {@link #classpathItem}; {@code null} when {@code fqcn} is unindexed. */
+    private static TypeHierarchyItem sourceItem(String fqcn, JavaSourceTypeIndex sourceIndex) {
+        Path file = sourceIndex.fileOf(fqcn);
+        JavaSourceType type = sourceIndex.byFqcn(fqcn);
+        if (file == null || type == null) {
             return null;
         }
-        TypeHierarchyItem item = new TypeHierarchyItem(simpleName(fqcn), res.kind,
-                res.location.getUri(), res.location.getRange(), res.location.getRange());
+        Range range = new Range(new Position(type.declLine, type.declColumn),
+                                new Position(type.declLine, type.declColumn + type.simpleName.length()));
+        SymbolKind kind = type.isEnum ? SymbolKind.Enum : SymbolKind.Class;
+        TypeHierarchyItem item = new TypeHierarchyItem(type.simpleName, kind,
+                file.toUri().toString(), range, range);
         item.setData(fqcn);
         item.setDetail(fqcn);
         return item;
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/Field.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/Field.java
index be69e1d4006..0321860fb5e 100644
--- a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/Field.java
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/Field.java
@@ -22,6 +22,9 @@ package org.drools.completion;
 /** A field, bean property, or enum constant of a {@link DeclaredType} or Java class. */
 public class Field {
 
+    /** Where a member came from, so hover can group fields, getters, and enum constants separately. */
+    public enum Origin { FIELD, GETTER, ENUM_CONSTANT }
+
     public final String name;
     public final String type;
     /**
@@ -31,14 +34,20 @@ public class Field {
      * and for enum constants without arguments.
      */
     public final String args;
+    public final Origin origin;
 
     Field(String name, String type) {
-        this(name, type, null);
+        this(name, type, null, Origin.FIELD);
     }
 
     Field(String name, String type, String args) {
+        this(name, type, args, Origin.FIELD);
+    }
+
+    Field(String name, String type, String args, Origin origin) {
         this.name = name;
         this.type = type;
         this.args = args;
+        this.origin = origin;
     }
 }
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaMemberSource.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaMemberSource.java
new file mode 100644
index 00000000000..d07206916ee
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaMemberSource.java
@@ -0,0 +1,49 @@
+/*
+ * 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.drools.completion;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * A source of type members that {@link ClassMemberIndex} can consult on a
+ * reflection miss (e.g. a type declared in {@code .java} source that has no
+ * compiled classpath entry yet), so completion/hover/lint keep working before
+ * a build exists.
+ *
+ * <p>Contracts mirror {@code ClassMemberIndex}'s own: {@link #membersOf} is
+ * empty (never {@code null}) when {@code fqcn} is unknown; {@link #memberNames}
+ * returns {@code null} when unknown, letting callers distinguish "no such
+ * member" (a real typo) from "couldn't verify" (classpath/source gap) — see
+ * {@link ClassMemberIndex#memberNames}; {@link #supertypesOf} returns direct
+ * supertypes as fully-qualified names where resolvable within the source
+ * index — unresolvable supertypes are omitted, never guessed at — empty when
+ * unknown; {@link #constructorsOf} returns signatures, empty when unknown.
+ */
+public interface JavaMemberSource {
+
+    List<Field> membersOf(String fqcn);
+
+    Set<String> memberNames(String fqcn);
+
+    List<String> supertypesOf(String fqcn);
+
+    List<String> constructorsOf(String fqcn);
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceRoots.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceRoots.java
new file mode 100644
index 00000000000..433f97710c3
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceRoots.java
@@ -0,0 +1,146 @@
+/*
+ * 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.drools.completion;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.logging.Logger;
+import java.util.stream.Stream;
+
+/**
+ * Discovers Java source roots to feed {@link JavaSourceTypeIndex#build}: a
+ * bounded Maven-convention walk of {@code workspaceRoot} for directories
+ * ending {@code src/main/java}, unioned with each of {@code configuredPaths}
+ * that resolves to an existing directory, with roots nested inside other roots
+ * collapsed away.
+ */
+public final class JavaSourceRoots {
+
+    private static final Logger logger = Logger.getLogger(JavaSourceRoots.class.getName());
+
+    private static final int MAX_DEPTH = 8;
+    private static final Path SRC_MAIN_JAVA = Path.of("src", "main", "java");
+
+    private JavaSourceRoots() {
+    }
+
+    /**
+     * Convention roots (dirs under {@code workspaceRoot}, depth ≤ {@value
+     * #MAX_DEPTH}, ending {@code src/main/java}) unioned with {@code
+     * configuredPaths} (each absolute, or resolved against {@code
+     * workspaceRoot}) that exist and are directories. Both contributions are
+     * always included — auto-discovery is not a fallback for configured
+     * entries. Walk failures are swallowed (logged at FINE) so a partially
+     * unreadable tree still yields whatever roots were found. Order:
+     * convention roots in walk-encounter order, then configured entries;
+     * duplicates (by normalized absolute path) are dropped.
+     */
+    public static List<Path> discover(Path workspaceRoot, List<String> configuredPaths) {
+        LinkedHashSet<Path> seen = new LinkedHashSet<>();
+        List<Path> out = new ArrayList<>();
+
+        if (workspaceRoot != null && Files.isDirectory(workspaceRoot)) {
+            try (Stream<Path> walk = Files.walk(workspaceRoot, MAX_DEPTH)) {
+                walk.filter(Files::isDirectory)
+                        .filter(p -> p.endsWith(SRC_MAIN_JAVA))
+                        .forEach(p -> addIfNew(out, seen, p));
+            } catch (IOException | RuntimeException e) {
+                logger.fine(() -> "Failed to walk " + workspaceRoot + " for source roots: " + e.getMessage());
+            }
+        }
+
+        if (configuredPaths != null) {
+            for (String configured : configuredPaths) {
+                if (configured == null || configured.isBlank()) {
+                    continue;
+                }
+                Path candidate;
+                try {
+                    candidate = Path.of(configured);
+                } catch (InvalidPathException e) {
+                    // A setting is user input, and on Windows a glob character
+                    // is not a legal path character at all. Skipping one entry
+                    // must not cost the caller its other roots.
+                    logger.fine(() -> "Ignoring source path '" + configured
+                            + "': not a usable path (" + e.getMessage() + ")");
+                    continue;
+                }
+                if (!candidate.isAbsolute() && workspaceRoot != null) {
+                    candidate = workspaceRoot.resolve(candidate);
+                }
+                if (Files.isDirectory(candidate)) {
+                    addIfNew(out, seen, candidate);
+                } else {
+                    logger.fine(() -> "Ignoring source path '" + configured
+                            + "': not an existing directory");
+                }
+            }
+        }
+
+        return collapseNested(out);
+    }
+
+    /**
+     * Drops every root that lies inside another root, keeping the outermost.
+     * Indexing walks each root recursively, so an ancestor already covers its
+     * descendants: a configured {@code module/src} alongside the discovered
+     * {@code module/src/main/java} would otherwise parse every file under
+     * {@code src/main/java} twice. The ancestor is the one kept because it is
+     * the broader instruction — a caller naming {@code src} means to index
+     * everything beneath it, test sources included.
+     */
+    private static List<Path> collapseNested(List<Path> roots) {
+        List<Path> out = new ArrayList<>(roots.size());
+        for (Path candidate : roots) {
+            boolean insideAnother = false;
+            for (Path other : roots) {
+                if (candidate != other && isInside(candidate, other)) {
+                    insideAnother = true;
+                    break;
+                }
+            }
+            if (!insideAnother) {
+                out.add(candidate);
+            } else {
+                logger.fine(() -> "Source root " + candidate + " lies inside another root; skipping");
+            }
+        }
+        return out;
+    }
+
+    /** True when {@code inner} is a strict descendant of {@code outer}. */
+    private static boolean isInside(Path inner, Path outer) {
+        Path a = inner.toAbsolutePath().normalize();
+        Path b = outer.toAbsolutePath().normalize();
+        return !a.equals(b) && a.startsWith(b);
+    }
+
+    private static void addIfNew(List<Path> out, LinkedHashSet<Path> seen, Path p) {
+        Path key = p.toAbsolutePath().normalize();
+        if (seen.add(key)) {
+            out.add(p);
+        }
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceType.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceType.java
new file mode 100644
index 00000000000..c7f23582c9c
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceType.java
@@ -0,0 +1,68 @@
+/*
+ * 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.drools.completion;
+
+import java.util.List;
+
+/**
+ * A Java type parsed from {@code .java} source (top-level class, enum, interface,
+ * or record). Carries the members, constructor signatures, supertype simple
+ * names, and the declaration's source position, so the source-typing layer can
+ * feed completion/hover/lint/definition before a compile exists. Immutable.
+ */
+public final class JavaSourceType {
+
+    public final String fqcn;
+    public final String simpleName;
+    public final boolean isEnum;
+    /** Simple name of the {@code extends} supertype, or {@code null}. */
+    public final String extendsSimpleName;
+    /** Simple names of implemented (or, for interfaces, extended) types. */
+    public final List<String> interfaceSimpleNames;
+    /** Fields, bean-property getters, and enum constants, deduped by name. */
+    public final List<Field> members;
+    /** Constructor signatures, e.g. {@code "Foo(int, String)"}. */
+    public final List<String> constructors;
+    /**
+     * Names of the public static fields. Not members — a static is not a fact
+     * property — but nameable through {@code Type.NAME}, which is what
+     * {@link JavaSourceTypeIndex#memberNames} answers for.
+     */
+    public final List<String> staticFieldNames;
+    /** 0-based line/column of the type's name token. */
+    public final int declLine;
+    public final int declColumn;
+
+    JavaSourceType(String fqcn, String simpleName, boolean isEnum, String extendsSimpleName,
+                   List<String> interfaceSimpleNames, List<Field> members,
+                   List<String> constructors, List<String> staticFieldNames,
+                   int declLine, int declColumn) {
+        this.fqcn = fqcn;
+        this.simpleName = simpleName;
+        this.isEnum = isEnum;
+        this.extendsSimpleName = extendsSimpleName;
+        this.interfaceSimpleNames = List.copyOf(interfaceSimpleNames);
+        this.members = List.copyOf(members);
+        this.constructors = List.copyOf(constructors);
+        this.staticFieldNames = List.copyOf(staticFieldNames);
+        this.declLine = declLine;
+        this.declColumn = declColumn;
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeIndex.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeIndex.java
new file mode 100644
index 00000000000..7c2727c8ff1
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeIndex.java
@@ -0,0 +1,379 @@
+/*
+ * 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.drools.completion;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.logging.Logger;
+import java.util.stream.Stream;
+
+/**
+ * Indexes {@code .java} source under a workspace's source roots via {@link
+ * JavaSourceTypeParser}, so completion/hover/lint can resolve types and
+ * members before a compile exists. An instance is an immutable snapshot;
+ * callers rebuild via {@link #build} on workspace changes rather than
+ * mutating one in place — the same model {@code ClassIndex} uses.
+ */
+public final class JavaSourceTypeIndex implements JavaMemberSource {
+
+    private static final Logger logger = Logger.getLogger(JavaSourceTypeIndex.class.getName());
+
+    private static final JavaSourceTypeIndex EMPTY =
+            new JavaSourceTypeIndex(Set.of(), Map.of(), Map.of(), Map.of());
+
+    private static final class CachedEntry {
+        final long modMillis;
+        final List<JavaSourceType> types;
+
+        CachedEntry(long modMillis, List<JavaSourceType> types) {
+            this.modMillis = modMillis;
+            this.types = types;
+        }
+    }
+
+    /** Per-file cache keyed by normalized absolute path, valid by mtime. Shared across builds. */
+    private static final Map<Path, CachedEntry> FILE_CACHE = new ConcurrentHashMap<>();
+
+    /** The root set most recently announced at INFO, so a rebuild over the same roots stays quiet. */
+    private static volatile Set<Path> lastAnnouncedRoots = Set.of();
+
+    /**
+     * Drops all cached parse results. Entries are already mtime-validated, so
+     * this is about bounding memory in a long-running server rather than
+     * correctness; call it when the workspace source roots are rebuilt or on
+     * shutdown.
+     */
+    public static void clearCache() {
+        FILE_CACHE.clear();
+    }
+
+    private final Set<Path> roots;
+    private final Map<String, List<String>> classNames;
+    private final Map<String, JavaSourceType> typesByFqcn;
+    private final Map<String, Path> fileByFqcn;
+
+    private JavaSourceTypeIndex(Set<Path> roots, Map<String, List<String>> classNames,
+                                 Map<String, JavaSourceType> typesByFqcn, Map<String, Path> fileByFqcn) {
+        this.roots = roots;
+        this.classNames = classNames;
+        this.typesByFqcn = typesByFqcn;
+        this.fileByFqcn = fileByFqcn;
+    }
+
+    /** An index over no source roots; resolves nothing. */
+    public static JavaSourceTypeIndex empty() {
+        return EMPTY;
+    }
+
+    /**
+     * Walks each of {@code sourceRoots} for {@code .java} files, parses each
+     * (via the mtime cache) into its top-level types, and keeps those whose
+     * package passes {@code packageFilters} — a type's package is its FQCN
+     * minus the last segment; it passes when {@code packageFilters} is empty,
+     * when a filter equals the package exactly, or when a filter ends with
+     * {@code *} and the package starts with the prefix before it. A
+     * default-package type passes only when {@code packageFilters} is empty.
+     * On a duplicate FQCN across roots, the first one seen wins (walk order);
+     * later ones are logged at FINE and dropped.
+     */
+    public static JavaSourceTypeIndex build(Set<Path> sourceRoots, List<String> packageFilters) {
+        List<String> filters = packageFilters == null ? List.of() : packageFilters;
+        Set<Path> usedRoots = new LinkedHashSet<>();
+        Map<String, JavaSourceType> typesByFqcn = new LinkedHashMap<>();
+        Map<String, Path> fileByFqcn = new LinkedHashMap<>();
+
+        if (sourceRoots != null) {
+            for (Path root : sourceRoots) {
+                if (root == null || !Files.isDirectory(root)) {
+                    continue;
+                }
+                usedRoots.add(root);
+                indexRoot(root, filters, typesByFqcn, fileByFqcn);
+            }
+        }
+
+        // The roots are the one fact needed to explain everything else this
+        // index reports — including duplicate-FQCN drops, which are expected
+        // when two roots legitimately see the same file and alarming otherwise.
+        // Announced only when the set changes, though: a rebuild runs on every
+        // watched .java save, and repeating this at INFO would bury the log it
+        // is meant to inform.
+        if (!usedRoots.isEmpty()) {
+            String message = "Indexed " + typesByFqcn.size() + " Java source type(s) from "
+                    + usedRoots.size() + " root(s): " + usedRoots;
+            if (usedRoots.equals(lastAnnouncedRoots)) {
+                logger.fine(message);
+            } else {
+                lastAnnouncedRoots = Set.copyOf(usedRoots);
+                logger.info(message);
+            }
+        }
+
+        Map<String, List<String>> classNames = new LinkedHashMap<>();
+        for (JavaSourceType type : typesByFqcn.values()) {
+            classNames.computeIfAbsent(type.simpleName, k -> new ArrayList<>()).add(type.fqcn);
+        }
+        Map<String, List<String>> classNamesOut = new LinkedHashMap<>();
+        for (Map.Entry<String, List<String>> entry : classNames.entrySet()) {
+            classNamesOut.put(entry.getKey(), List.copyOf(entry.getValue()));
+        }
+
+        return new JavaSourceTypeIndex(Set.copyOf(usedRoots), Map.copyOf(classNamesOut),
+                Map.copyOf(typesByFqcn), Map.copyOf(fileByFqcn));
+    }
+
+    /**
+     * Indexes files one at a time (rather than collecting the walk to a list
+     * first) so a failure partway through the walk — an unreadable
+     * subdirectory, say — still leaves everything found before it in {@code
+     * typesByFqcn}/{@code fileByFqcn}; only the remainder of this root is
+     * lost.
+     */
+    private static void indexRoot(Path root, List<String> filters,
+                                   Map<String, JavaSourceType> typesByFqcn, Map<String, Path> fileByFqcn) {
+        try (Stream<Path> walk = Files.walk(root)) {
+            walk.filter(Files::isRegularFile)
+                    .filter(p -> p.toString().endsWith(".java"))
+                    .forEach(file -> indexFile(file, filters, typesByFqcn, fileByFqcn));
+        } catch (IOException | RuntimeException e) {
+            logger.fine(() -> "Failed to walk source root " + root + ": " + e.getMessage());
+        }
+    }
+
+    private static void indexFile(Path file, List<String> filters,
+                                   Map<String, JavaSourceType> typesByFqcn, Map<String, Path> fileByFqcn) {
+        for (JavaSourceType type : cachedParse(file)) {
+            if (!passesFilter(type.fqcn, filters)) {
+                continue;
+            }
+            if (typesByFqcn.containsKey(type.fqcn)) {
+                logger.fine(() -> "Duplicate FQCN " + type.fqcn + " from " + file + " ignored (first wins)");
+                continue;
+            }
+            typesByFqcn.put(type.fqcn, type);
+            fileByFqcn.put(type.fqcn, file);
+        }
+    }
+
+    private static boolean passesFilter(String fqcn, List<String> filters) {
+        if (filters.isEmpty()) {
+            return true;
+        }
+        int dot = fqcn.lastIndexOf('.');
+        String pkg = dot >= 0 ? fqcn.substring(0, dot) : "";
+        if (pkg.isEmpty()) {
+            return false;
+        }
+        for (String filter : filters) {
+            if (!filter.endsWith("*")) {
+                if (pkg.equals(filter)) {
+                    return true;
+                }
+                continue;
+            }
+            String prefix = filter.substring(0, filter.length() - 1);
+            // "com.example.*" means that package and everything under it. Read as
+            // a bare prefix it would exclude com.example itself, which is the one
+            // package the author certainly meant to include.
+            if (prefix.endsWith(".") && pkg.equals(prefix.substring(0, prefix.length() - 1))) {
+                return true;
+            }
+            if (pkg.startsWith(prefix)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Parses a file's top-level types, serving a cached result while the
+     * file's modification time is unchanged. Missing/unreadable files yield
+     * an empty list.
+     */
+    private static List<JavaSourceType> cachedParse(Path file) {
+        if (file == null || !Files.isRegularFile(file)) {
+            return List.of();
+        }
+        try {
+            Path key = file.toAbsolutePath().normalize();
+            long modMillis = Files.getLastModifiedTime(file).toMillis();
+            CachedEntry cached = FILE_CACHE.get(key);
+            if (cached != null && cached.modMillis == modMillis) {
+                return cached.types;
+            }
+            List<JavaSourceType> types = JavaSourceTypeParser.parse(Files.readString(file));
+            FILE_CACHE.put(key, new CachedEntry(modMillis, types));
+            return types;
+        } catch (Exception e) {
+            logger.fine(() -> "Failed to read/parse " + file + ": " + e.getMessage());
+            return List.of();
+        }
+    }
+
+    /** Simple name → FQCNs, for merging into {@code ClassIndex}'s own name map. */
+    public Map<String, List<String>> classNames() {
+        return classNames;
+    }
+
+    /** The parsed model for {@code fqcn}, or {@code null} when unknown. */
+    public JavaSourceType byFqcn(String fqcn) {
+        return fqcn == null ? null : typesByFqcn.get(fqcn);
+    }
+
+    /** The source file {@code fqcn} was parsed from, or {@code null} when unknown. */
+    public Path fileOf(String fqcn) {
+        return fqcn == null ? null : fileByFqcn.get(fqcn);
+    }
+
+    /** The source roots this index was built from, for logging/tests. */
+    public Set<Path> roots() {
+        return roots;
+    }
+
+    @Override
+    public List<Field> membersOf(String fqcn) {
+        JavaSourceType type = byFqcn(fqcn);
+        return type == null ? List.of() : List.copyOf(membersIncludingInherited(fqcn, type).values());
+    }
+
+    @Override
+    /**
+     * Every name reachable through {@code Type.NAME}, which is wider than the
+     * member list: it adds the public static fields, because a constant is not a
+     * fact property but is still written that way. Mirrors reflection's
+     * {@code Class#getFields()}, which the compiled path uses here and which
+     * likewise includes statics — without this the unknown-type lint would call
+     * {@code Type.CONSTANT} an unknown member until the type is compiled.
+     */
+    public Set<String> memberNames(String fqcn) {
+        JavaSourceType type = byFqcn(fqcn);
+        if (type == null) {
+            return null;
+        }
+        Set<String> names = new LinkedHashSet<>(membersIncludingInherited(fqcn, type).keySet());
+        names.addAll(type.staticFieldNames);
+        return names;
+    }
+
+    /**
+     * Returns {@code type}'s own members followed by those of its ancestors,
+     * walked through {@code extendsSimpleName} within this index only, so a
+     * source-only subclass shows the same member set the reflection path
+     * (which walks {@code getMethods()}/superclass automatically) would show
+     * once a build replaces it. Deduped by name via {@code putIfAbsent} — own
+     * members and nearer ancestors win over farther ones. Depth-capped at 10
+     * and cycle-guarded by a seen-FQCN set, mirroring {@code
+     * DRLDeclaredTypeParser#fieldsIncludingInherited}'s shape. Stops (rather
+     * than guessing) the moment a parent's simple name can't be resolved to
+     * exactly one FQCN in this index.
+     */
+    private Map<String, Field> membersIncludingInherited(String fqcn, JavaSourceType type) {
+        Map<String, Field> members = new LinkedHashMap<>();
+        for (Field f : type.members) {
+            members.putIfAbsent(f.name, f);
+        }
+        Set<String> seen = new HashSet<>();
+        seen.add(fqcn);
+        String currentFqcn = fqcn;
+        String parentSimpleName = type.extendsSimpleName;
+        int depth = 0;
+        while (parentSimpleName != null && depth++ < 10) {
+            String parentFqcn = resolveParentFqcn(currentFqcn, parentSimpleName);
+            if (parentFqcn == null || !seen.add(parentFqcn)) {
+                break;
+            }
+            JavaSourceType parent = typesByFqcn.get(parentFqcn);
+            if (parent == null) {
+                break;
+            }
+            for (Field f : parent.members) {
+                members.putIfAbsent(f.name, f);
+            }
+            currentFqcn = parentFqcn;
+            parentSimpleName = parent.extendsSimpleName;
+        }
+        return members;
+    }
+
+    /**
+     * Resolves {@code parentSimpleName} (an {@code extends} target) to an
+     * FQCN within this index: first the same package as {@code childFqcn}
+     * (the common case), else — only when {@code classNames()} maps the
+     * simple name to exactly one FQCN — that unique cross-package match.
+     * Returns {@code null} (stop the walk) rather than guess among several
+     * same-named candidates.
+     */
+    private String resolveParentFqcn(String childFqcn, String parentSimpleName) {
+        int dot = childFqcn.lastIndexOf('.');
+        String childPackage = dot >= 0 ? childFqcn.substring(0, dot) : "";
+        String samePackageCandidate = childPackage.isEmpty()
+                ? parentSimpleName : childPackage + "." + parentSimpleName;
+        if (typesByFqcn.containsKey(samePackageCandidate)) {
+            return samePackageCandidate;
+        }
+        List<String> candidates = classNames.get(parentSimpleName);
+        return (candidates != null && candidates.size() == 1) ? candidates.get(0) : null;
+    }
+
+    /**
+     * Returns {@code fqcn}'s direct supertypes — its {@code extends} target
+     * followed by its directly-implemented interfaces — resolved to FQCNs via
+     * {@link #resolveParentFqcn} (same package first, else a unique
+     * cross-package simple-name match within this index). A supertype that
+     * can't be resolved that way is omitted rather than guessed at, mirroring
+     * {@link #membersIncludingInherited}'s stop-don't-guess discipline.
+     */
+    @Override
+    public List<String> supertypesOf(String fqcn) {
+        JavaSourceType type = byFqcn(fqcn);
+        if (type == null) {
+            return List.of();
+        }
+        List<String> out = new ArrayList<>();
+        if (type.extendsSimpleName != null) {
+            String resolved = resolveParentFqcn(fqcn, type.extendsSimpleName);
+            if (resolved != null) {
+                out.add(resolved);
+            }
+        }
+        for (String interfaceSimpleName : type.interfaceSimpleNames) {
+            String resolved = resolveParentFqcn(fqcn, interfaceSimpleName);
+            if (resolved != null) {
+                out.add(resolved);
+            }
+        }
+        return out;
+    }
+
+    @Override
+    public List<String> constructorsOf(String fqcn) {
+        JavaSourceType type = byFqcn(fqcn);
+        return type == null ? List.of() : type.constructors;
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeParser.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeParser.java
new file mode 100644
index 00000000000..a20a7803be2
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/JavaSourceTypeParser.java
@@ -0,0 +1,462 @@
+/*
+ * 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.drools.completion;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Logger;
+
+import org.antlr.v4.runtime.BaseErrorListener;
+import org.antlr.v4.runtime.CharStreams;
+import org.antlr.v4.runtime.CommonTokenStream;
+import org.antlr.v4.runtime.RecognitionException;
+import org.antlr.v4.runtime.Recognizer;
+import org.drools.drl.parser.antlr4.JavaLexer;
+import org.drools.drl.parser.antlr4.JavaParser;
+
+/**
+ * Parses {@code .java} source into {@link JavaSourceType}s using the ANTLR Java
+ * grammar generated into the {@code drools-parser} jar
+ * ({@code org.drools.drl.parser.antlr4.JavaParser}). Only top-level types are
+ * indexed; nested types are skipped. Best-effort: syntax errors are silenced so
+ * partial/edited buffers still yield whatever parsed cleanly, and the parser
+ * never throws.
+ *
+ * <p>Known limits (acceptable for typing/hover/lint): nested types are not
+ * indexed; interface member extraction is name-first (fields/constants may be
+ * partial); generic type arguments and array dimensions are erased to the raw
+ * simple name.
+ */
+public final class JavaSourceTypeParser {
+
+    private static final Logger logger = Logger.getLogger(JavaSourceTypeParser.class.getName());
+
+    private static final BaseErrorListener SILENT = new BaseErrorListener() {
+        @Override
+        public void syntaxError(Recognizer<?, ?> r, Object sym, int line, int col,
+                                String msg, RecognitionException e) {
+        }
+    };
+
+    private JavaSourceTypeParser() {
+    }
+
+    public static List<JavaSourceType> parse(String source) {
+        if (source == null || source.isBlank()) {
+            return Collections.emptyList();
+        }
+        try {
+            JavaLexer lexer = new JavaLexer(CharStreams.fromString(source));
+            lexer.removeErrorListeners();
+            lexer.addErrorListener(SILENT);
+            JavaParser parser = new JavaParser(new CommonTokenStream(lexer));
+            parser.removeErrorListeners();
+            parser.addErrorListener(SILENT);
+
+            JavaParser.CompilationUnitContext cu = parser.compilationUnit();
+            if (cu == null) {
+                return Collections.emptyList();
+            }
+            String pkg = (cu.packageDeclaration() != null
+                    && cu.packageDeclaration().qualifiedName() != null)
+                    ? cu.packageDeclaration().qualifiedName().getText() : "";
+
+            List<JavaSourceType> out = new ArrayList<>();
+            for (JavaParser.TypeDeclarationContext td : cu.typeDeclaration()) {
+                try {
+                    JavaSourceType t = fromTypeDeclaration(td, pkg);
+                    if (t != null) {
+                        out.add(t);
+                    }
+                } catch (Exception e) {
+                    logger.fine(() -> "Skipping malformed top-level type: " + e.getMessage());
+                }
+            }
+            return out;
+        } catch (Exception e) {
+            logger.fine(() -> "Failed to parse Java source: " + e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    private static JavaSourceType fromTypeDeclaration(JavaParser.TypeDeclarationContext td, String pkg) {
+        if (td.classDeclaration() != null) {
+            return fromClass(td.classDeclaration(), pkg);
+        }
+        if (td.enumDeclaration() != null) {
+            return fromEnum(td.enumDeclaration(), pkg);
+        }
+        if (td.interfaceDeclaration() != null) {
+            return fromInterface(td.interfaceDeclaration(), pkg);
+        }
+        if (td.recordDeclaration() != null) {
+            return fromRecord(td.recordDeclaration(), pkg);
+        }
+        return null; // annotation type / bare ';'
+    }
+
+    private static JavaSourceType fromClass(JavaParser.ClassDeclarationContext cd, String pkg) {
+        String simpleName = cd.identifier().getText();
+        String extendsName = extendsSimpleNameOf(cd.typeType());
+        List<String> interfaces = (cd.IMPLEMENTS() != null && !cd.typeList().isEmpty())
+                ? simplifyAll(cd.typeList(0)) : List.of();
+
+        List<Field> fields = new ArrayList<>();
+        List<Field> getters = new ArrayList<>();
+        List<String> ctors = new ArrayList<>();
+        List<String> staticFields = new ArrayList<>();
+        if (cd.classBody() != null) {
+            collectBodyMembers(cd.classBody().classBodyDeclaration(), fields, getters, ctors,
+                    staticFields, simpleName);
+        }
+
+        Map<String, Field> members = new LinkedHashMap<>();
+        mergeGettersThenFields(members, getters, fields);
+
+        return new JavaSourceType(fqcn(pkg, simpleName), simpleName, false, extendsName, interfaces,
+                new ArrayList<>(members.values()), ctors, staticFields,
+                declLine(cd.identifier()), declColumn(cd.identifier()));
+    }
+
+    private static JavaSourceType fromEnum(JavaParser.EnumDeclarationContext ed, String pkg) {
+        String simpleName = ed.identifier().getText();
+        List<String> interfaces = (ed.IMPLEMENTS() != null && ed.typeList() != null)
+                ? simplifyAll(ed.typeList()) : List.of();
+
+        Map<String, Field> members = new LinkedHashMap<>();
+        if (ed.enumConstants() != null) {
+            for (JavaParser.EnumConstantContext ec : ed.enumConstants().enumConstant()) {
+                String name = ec.identifier().getText();
+                String args = ec.arguments() != null ? argsText(ec.arguments()) : null;
+                members.put(name, new Field(name, simpleName, args, Field.Origin.ENUM_CONSTANT));
+            }
+        }
+
+        List<Field> fields = new ArrayList<>();
+        List<Field> getters = new ArrayList<>();
+        List<String> ctors = new ArrayList<>();
+        List<String> staticFields = new ArrayList<>();
+        if (ed.enumBodyDeclarations() != null) {
+            collectBodyMembers(ed.enumBodyDeclarations().classBodyDeclaration(), fields, getters, ctors,
+                    staticFields, simpleName);
+        }
+        mergeGettersThenFields(members, getters, fields);
+
+        return new JavaSourceType(fqcn(pkg, simpleName), simpleName, true, null, interfaces,
+                new ArrayList<>(members.values()), ctors, staticFields,
+                declLine(ed.identifier()), declColumn(ed.identifier()));
+    }
+
+    private static JavaSourceType fromInterface(JavaParser.InterfaceDeclarationContext id, String pkg) {
+        String simpleName = id.identifier().getText();
+        List<String> interfaces = (id.EXTENDS() != null && !id.typeList().isEmpty())
+                ? simplifyAll(id.typeList(0)) : List.of();
+
+        List<Field> fields = new ArrayList<>();
+        List<Field> getters = new ArrayList<>();
+        if (id.interfaceBody() != null) {
+            for (JavaParser.InterfaceBodyDeclarationContext ibd : id.interfaceBody().interfaceBodyDeclaration()) {
+                try {
+                    collectInterfaceMember(ibd, fields, getters);
+                } catch (Exception e) {
+                    logger.fine(() -> "Skipping interface member in " + simpleName + ": " + e.getMessage());
+                }
+            }
+        }
+        Map<String, Field> members = new LinkedHashMap<>();
+        mergeGettersThenFields(members, getters, fields);
+
+        return new JavaSourceType(fqcn(pkg, simpleName), simpleName, false, null, interfaces,
+                new ArrayList<>(members.values()), List.of(), List.of(),
+                declLine(id.identifier()), declColumn(id.identifier()));
+    }
+
+    private static JavaSourceType fromRecord(JavaParser.RecordDeclarationContext rd, String pkg) {
+        String simpleName = rd.identifier().getText();
+        List<String> interfaces = (rd.IMPLEMENTS() != null && rd.typeList() != null)
+                ? simplifyAll(rd.typeList()) : List.of();
+
+        List<JavaParser.RecordComponentContext> components =
+                (rd.recordHeader() != null && rd.recordHeader().recordComponentList() != null)
+                        ? rd.recordHeader().recordComponentList().recordComponent() : List.of();
+
+        // Records expose components only as accessor methods — there is no
+        // separate private field worth modeling — so each component is a
+        // single GETTER, consistent with getters beating fields elsewhere.
+        Map<String, Field> members = new LinkedHashMap<>();
+        List<String> ctorTypes = new ArrayList<>();
+        for (JavaParser.RecordComponentContext rc : components) {
+            String name = rc.identifier().getText();
+            String type = simplify(rc.typeType());
+            members.putIfAbsent(name, new Field(name, type, null, Field.Origin.GETTER));
+            ctorTypes.add(type);
+        }
+        String canonicalCtor = simpleName + "(" + String.join(", ", ctorTypes) + ")";
+
+        return new JavaSourceType(fqcn(pkg, simpleName), simpleName, false, null, interfaces,
+                new ArrayList<>(members.values()), List.of(canonicalCtor), List.of(),
+                declLine(rd.identifier()), declColumn(rd.identifier()));
+    }
+
+    /**
+     * Merges getters then fields into {@code into} via {@code putIfAbsent} —
+     * a getter beats a same-named field. This mirrors the insertion order
+     * {@code ClassMemberIndex.reflectMembers} uses when reflecting a compiled
+     * class, so a source-parsed type's member list doesn't reshuffle once a
+     * build replaces it with the reflected view.
+     */
+    private static void mergeGettersThenFields(Map<String, Field> into, List<Field> getters, List<Field> fields) {
+        for (Field f : getters) {
+            into.putIfAbsent(f.name, f);
+        }
+        for (Field f : fields) {
+            into.putIfAbsent(f.name, f);
+        }
+    }
+
+    /**
+     * Walks a class/enum body's declarations, sorting each into a field,
+     * getter, or constructor-signature list. Per-member failures are
+     * swallowed so one malformed declaration doesn't drop the rest.
+     */
+    private static void collectBodyMembers(List<JavaParser.ClassBodyDeclarationContext> decls,
+                                            List<Field> fieldsOut, List<Field> gettersOut,
+                                            List<String> ctorsOut, List<String> staticFieldsOut,
+                                            String simpleName) {
+        for (JavaParser.ClassBodyDeclarationContext cbd : decls) {
+            try {
+                JavaParser.MemberDeclarationContext md = cbd.memberDeclaration();
+                if (md == null) {
+                    continue; // static block or bare ';'
+                }
+                if (md.fieldDeclaration() != null && hasPublicModifier(cbd.modifier())) {
+                    JavaParser.FieldDeclarationContext fd = md.fieldDeclaration();
+                    String type = simplify(fd.typeType());
+                    boolean isStatic = hasStaticModifier(cbd.modifier());
+                    for (JavaParser.VariableDeclaratorContext vd : fd.variableDeclarators().variableDeclarator()) {
+                        String name = vd.variableDeclaratorId().identifier().getText();
+                        if (isStatic) {
+                            // Kept by name only: reachable as Type.NAME, but not a
+                            // property of a fact, so out of the member list.
+                            staticFieldsOut.add(name);
+                        } else {
+                            fieldsOut.add(new Field(name, type, null, Field.Origin.FIELD));
+                        }
+                    }
+                } else if (md.methodDeclaration() != null && isPublicInstanceMember(cbd.modifier())) {
+                    JavaParser.MethodDeclarationContext mt = md.methodDeclaration();
+                    String property = getterPropertyOf(mt.typeTypeOrVoid(), mt.identifier(), mt.formalParameters());
+                    if (property != null) {
+                        gettersOut.add(new Field(property, simplify(mt.typeTypeOrVoid().typeType()), null,
+                                Field.Origin.GETTER));
+                    }
+                } else if (md.constructorDeclaration() != null && hasPublicModifier(cbd.modifier())) {
+                    ctorsOut.add(constructorSignature(simpleName, md.constructorDeclaration().formalParameters()));
+                }
+            } catch (Exception e) {
+                logger.fine(() -> "Skipping class member in " + simpleName + ": " + e.getMessage());
+            }
+        }
+    }
+
+    /**
+     * A public, non-static member — the shape reflection reports as an instance
+     * member, and so the only shape this view may claim. Statics are excluded
+     * rather than modelled separately: parity with the compiled path is what
+     * keeps a member from appearing before a build and vanishing after it.
+     */
+    private static boolean isPublicInstanceMember(List<JavaParser.ModifierContext> modifiers) {
+        return hasPublicModifier(modifiers) && !hasStaticModifier(modifiers);
+    }
+
+    private static boolean hasStaticModifier(List<JavaParser.ModifierContext> modifiers) {
+        for (JavaParser.ModifierContext modifier : modifiers) {
+            JavaParser.ClassOrInterfaceModifierContext coim = modifier.classOrInterfaceModifier();
+            if (coim != null && coim.STATIC() != null) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** True when {@code modifiers} includes {@code public}. */
+    private static boolean hasPublicModifier(List<JavaParser.ModifierContext> modifiers) {
+        for (JavaParser.ModifierContext modifier : modifiers) {
+            JavaParser.ClassOrInterfaceModifierContext coim = modifier.classOrInterfaceModifier();
+            if (coim != null && coim.PUBLIC() != null) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** Extracts a {@code constDeclaration} or no-arg getter from one interface body member, if any. */
+    private static void collectInterfaceMember(JavaParser.InterfaceBodyDeclarationContext ibd,
+                                                List<Field> fieldsOut, List<Field> gettersOut) {
+        JavaParser.InterfaceMemberDeclarationContext imd = ibd.interfaceMemberDeclaration();
+        if (imd == null) {
+            return; // bare ';'
+        }
+        if (imd.constDeclaration() != null) {
+            JavaParser.ConstDeclarationContext cdecl = imd.constDeclaration();
+            String type = simplify(cdecl.typeType());
+            for (JavaParser.ConstantDeclaratorContext decl : cdecl.constantDeclarator()) {
+                String name = decl.identifier().getText();
+                fieldsOut.add(new Field(name, type, null, Field.Origin.FIELD));
+            }
+        } else if (imd.interfaceMethodDeclaration() != null) {
+            JavaParser.InterfaceCommonBodyDeclarationContext body =
+                    imd.interfaceMethodDeclaration().interfaceCommonBodyDeclaration();
+            String property = getterPropertyOf(body.typeTypeOrVoid(), body.identifier(), body.formalParameters());
+            if (property != null) {
+                gettersOut.add(new Field(property, simplify(body.typeTypeOrVoid().typeType()), null,
+                        Field.Origin.GETTER));
+            }
+        }
+    }
+
+    /**
+     * Maps a no-arg {@code getX()}/{@code isX()} declaration to its bean
+     * property name (JavaBeans decapitalize rule, mirroring
+     * {@code ClassMemberIndex.propertyNameOf}), or {@code null} for
+     * non-accessors and {@code getClass}.
+     */
+    private static String getterPropertyOf(JavaParser.TypeTypeOrVoidContext returnType,
+                                            JavaParser.IdentifierContext nameCtx,
+                                            JavaParser.FormalParametersContext params) {
+        if (returnType == null || returnType.typeType() == null || nameCtx == null) {
+            return null; // void return, or malformed
+        }
+        if (params != null && params.formalParameterList() != null) {
+            return null; // getters take no arguments
+        }
+        String name = nameCtx.getText();
+        if (name.startsWith("get") && name.length() > 3 && Character.isUpperCase(name.charAt(3))) {
+            if ("getClass".equals(name)) {
+                return null;
+            }
+            return decapitalize(name.substring(3));
+        }
+        if (name.startsWith("is") && name.length() > 2 && Character.isUpperCase(name.charAt(2))) {
+            String returnSimple = simplify(returnType.typeType());
+            if ("boolean".equals(returnSimple) || "Boolean".equals(returnSimple)) {
+                return decapitalize(name.substring(2));
+            }
+        }
+        return null;
+    }
+
+    /** JavaBeans decapitalize: keep as-is when the first two letters are both uppercase. */
+    private static String decapitalize(String raw) {
+        if (raw.length() > 1 && Character.isUpperCase(raw.charAt(0)) && Character.isUpperCase(raw.charAt(1))) {
+            return raw;
+        }
+        char[] chars = raw.toCharArray();
+        chars[0] = Character.toLowerCase(chars[0]);
+        return new String(chars);
+    }
+
+    private static String constructorSignature(String simpleName, JavaParser.FormalParametersContext params) {
+        List<String> types = new ArrayList<>();
+        JavaParser.FormalParameterListContext list = params == null ? null : params.formalParameterList();
+        if (list != null) {
+            for (JavaParser.FormalParameterContext fp : list.formalParameter()) {
+                types.add(simplify(fp.typeType()));
+            }
+            if (list.lastFormalParameter() != null) {
+                types.add(simplify(list.lastFormalParameter().typeType()) + "...");
+            }
+        }
+        return simpleName + "(" + String.join(", ", types) + ")";
+    }
+
+    private static List<String> simplifyAll(JavaParser.TypeListContext typeList) {
+        List<String> out = new ArrayList<>();
+        if (typeList != null) {
+            for (JavaParser.TypeTypeContext t : typeList.typeType()) {
+                out.add(simplify(t));
+            }
+        }
+        return out;
+    }
+
+    /**
+     * Simple name of an {@code extends} supertype: the last {@code identifier()}
+     * of its {@code classOrInterfaceType()} when present (avoids any ambiguity
+     * from generics/arrays in raw text), else the type's text run through
+     * {@link #simplify(String)}.
+     */
+    private static String extendsSimpleNameOf(JavaParser.TypeTypeContext typeType) {
+        if (typeType == null) {
+            return null;
+        }
+        JavaParser.ClassOrInterfaceTypeContext coit = typeType.classOrInterfaceType();
+        if (coit != null && !coit.identifier().isEmpty()) {
+            List<JavaParser.IdentifierContext> ids = coit.identifier();
+            return ids.get(ids.size() - 1).getText();
+        }
+        return simplify(typeType.getText());
+    }
+
+    private static String simplify(JavaParser.TypeTypeContext typeType) {
+        return typeType == null ? null : simplify(typeType.getText());
+    }
+
+    /** Strips generic type arguments and array brackets, then takes the last dotted segment. */
+    private static String simplify(String rawType) {
+        if (rawType == null) {
+            return null;
+        }
+        String t = rawType;
+        int generics = t.indexOf('<');
+        if (generics >= 0) {
+            t = t.substring(0, generics);
+        }
+        while (t.endsWith("[]")) {
+            t = t.substring(0, t.length() - 2);
+        }
+        int dot = t.lastIndexOf('.');
+        return dot >= 0 ? t.substring(dot + 1) : t;
+    }
+
+    /** The raw text between an {@code arguments()} node's parentheses. */
+    private static String argsText(JavaParser.ArgumentsContext args) {
+        String text = args.getText();
+        if (text.length() >= 2 && text.charAt(0) == '(' && text.charAt(text.length() - 1) == ')') {
+            return text.substring(1, text.length() - 1);
+        }
+        return text;
+    }
+
+    private static String fqcn(String pkg, String simpleName) {
+        return pkg.isEmpty() ? simpleName : pkg + "." + simpleName;
+    }
+
+    private static int declLine(JavaParser.IdentifierContext id) {
+        return id.getStart().getLine() - 1;
+    }
+
+    private static int declColumn(JavaParser.IdentifierContext id) {
+        return id.getStart().getCharPositionInLine();
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/LhsBindingResolver.java b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/LhsBindingResolver.java
index 748524994c4..34308dbd34c 100644
--- a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/LhsBindingResolver.java
+++ b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/LhsBindingResolver.java
@@ -158,13 +158,71 @@ public final class LhsBindingResolver {
         if (text == null || text.isEmpty()) {
             return bindings;
         }
-        Matcher rule = RULE_WHEN.matcher(text);
+        Matcher rule = RULE_WHEN.matcher(maskCommentsAndStrings(text));
         while (rule.find()) {
             bindings.putAll(collect(rule.group(1), typesByName, accumResultTypes).types);
         }
         return bindings;
     }
 
+    /**
+     * Replaces the contents of comments and string literals with spaces, keeping
+     * the text the same length so every offset into it still refers to the same
+     * character.
+     *
+     * <p>The section patterns here match bare keywords, and a rule's condition
+     * ends at the first {@code then}. Left unmasked, a comment reading
+     * "…if X then Y" or a constraint comparing against {@code "then"} ends the
+     * condition where it appears, hiding every binding past that point — and,
+     * when it lands inside a pattern, unbalancing that pattern's parentheses so
+     * even the bindings before it are lost. Quote characters themselves survive,
+     * so a masked literal is still a literal.
+     */
+    static String maskCommentsAndStrings(String text) {
+        char[] chars = text.toCharArray();
+        int i = 0;
+        while (i < chars.length) {
+            char c = chars[i];
+            if (c == '/' && i + 1 < chars.length && chars[i + 1] == '/') {
+                while (i < chars.length && chars[i] != '\n') {
+                    chars[i++] = ' ';
+                }
+            } else if (c == '/' && i + 1 < chars.length && chars[i + 1] == '*') {
+                chars[i++] = ' ';
+                chars[i++] = ' ';
+                while (i < chars.length
+                        && !(chars[i] == '*' && i + 1 < chars.length && chars[i + 1] == '/')) {
+                    blankUnlessNewline(chars, i++);
+                }
+                if (i < chars.length) {
+                    chars[i++] = ' ';
+                    chars[i++] = ' ';
+                }
+            } else if (c == '"' || c == '\'') {
+                i++;
+                while (i < chars.length && chars[i] != c) {
+                    if (chars[i] == '\\' && i + 1 < chars.length) {
+                        chars[i++] = ' ';
+                    }
+                    blankUnlessNewline(chars, i++);
+                }
+                if (i < chars.length) {
+                    i++;
+                }
+            } else {
+                i++;
+            }
+        }
+        return new String(chars);
+    }
+
+    /** Newlines stay, so masking never joins two lines into one. */
+    private static void blankUnlessNewline(char[] chars, int index) {
+        if (index < chars.length && chars[index] != '\n') {
+            chars[index] = ' ';
+        }
+    }
+
     /**
      * As {@link #resolve(String, Map)}, but scoped to the single rule block
      * enclosing {@code offset} (a character offset into {@code text}). Callers
@@ -189,7 +247,7 @@ public final class LhsBindingResolver {
         if (text == null || text.isEmpty()) {
             return new HashMap<>();
         }
-        Matcher block = RULE_BLOCK.matcher(text);
+        Matcher block = RULE_BLOCK.matcher(maskCommentsAndStrings(text));
         while (block.find()) {
             if (offset >= block.start() && offset < block.end()) {
                 return collect(block.group(1), typesByName, accumResultTypes).types;
@@ -237,7 +295,7 @@ public final class LhsBindingResolver {
 
             int bodyStart = openParen + 1;
             String body = whenSection.substring(bodyStart, closeParen);
-            DeclaredType declared = typesByName.get(typeName);
+            DeclaredType declared = typeOrClasspath(typeName, typesByName);
 
             Matcher fb = FIELD_BINDING.matcher(body);
             while (fb.find()) {
@@ -306,7 +364,9 @@ public final class LhsBindingResolver {
             if (current.extendsName == null) {
                 return null;
             }
-            current = typesByName.get(current.extendsName);
+            // The supertype may be a Java class rather than another declare, in
+            // which case only the host can describe it.
+            current = typeOrClasspath(current.extendsName, typesByName);
         }
         return null;
     }
@@ -316,8 +376,11 @@ public final class LhsBindingResolver {
      * resolving each segment's field type via {@link #lookupFieldType} and
      * advancing through {@code typesByName}. Returns the final segment's (simple)
      * type name, or {@code null} if any segment can't be resolved.
+     *
+     * <p>Also used by member completion after a dot, so that the path a caret
+     * sits at the end of resolves exactly as a binding on the same path would.
      */
-    private static String resolvePath(DeclaredType type, String path,
+    static String resolvePath(DeclaredType type, String path,
                                       Map<String, DeclaredType> typesByName) {
         DeclaredType current = type;
         String resolved = null;
@@ -329,11 +392,22 @@ public final class LhsBindingResolver {
             if (resolved == null) {
                 return null;
             }
-            current = typesByName.get(resolved);
+            current = typeOrClasspath(resolved, typesByName);
         }
         return resolved;
     }
 
+    /**
+     * The DRL-declared type named {@code name}, or — when none is declared — a
+     * stand-in built from the host's member lookup so bindings on classpath and
+     * workspace-source types resolve too.
+     */
+    static DeclaredType typeOrClasspath(String name,
+                                        Map<String, DeclaredType> typesByName) {
+        DeclaredType declared = typesByName.get(name);
+        return declared != null ? declared : ClasspathTypeMembers.asDeclaredType(name);
+    }
+
     private static String simpleName(String type) {
         if (type == null) {
             return null;
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/ClassIndexTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/ClassIndexTest.java
index eb118ef5b61..3cb995b6840 100644
--- a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/ClassIndexTest.java
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/ClassIndexTest.java
@@ -22,15 +22,36 @@ package org.drools.completion;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class ClassIndexTest {
 
+    /**
+     * The index is keyed by simple name, so answering "which FQCNs carry this
+     * exact name" needs no scan. getMatching is a prefix search — it also
+     * answers for OrderLine — which makes it the wrong tool for the callers
+     * that only want to confirm a single name.
+     */
+    @Test
+    void forSimpleNameIsExactWhereGetMatchingIsAPrefixSearch() {
+        ClassIndex index = ClassIndex.of(Map.of(
+                "Order", List.of("com.example.Order"),
+                "OrderLine", List.of("com.example.OrderLine")));
+
+        assertEquals(List.of("com.example.Order"), index.forSimpleName("Order"));
+        assertEquals(2, index.getMatching("Order").size(), "getMatching stays a prefix search");
+        assertTrue(index.forSimpleName("Nope").isEmpty());
+    }
+
     @TempDir
     Path tempDir;
 
@@ -130,4 +151,27 @@ class ClassIndexTest {
         assertThat(merged.getMatching("Or")).containsExactly("com.acme.Order");
         assertThat(merged.size()).isEqualTo(2);
     }
+
+    @Test
+    void ofBuildsFromNameMap() {
+        ClassIndex idx = ClassIndex.of(Map.of("Patient", List.of("com.example.Patient")));
+        assertTrue(idx.getMatching("Pat").contains("com.example.Patient"));
+        assertEquals(1, idx.size());
+    }
+
+    @Test
+    void mergeDeduplicatesSameFqcn() {
+        ClassIndex source = ClassIndex.of(Map.of("Patient", List.of("com.example.Patient")));
+        ClassIndex compiled = ClassIndex.of(Map.of("Patient", List.of("com.example.Patient")));
+        ClassIndex merged = ClassIndex.merge(source, compiled);
+        assertEquals(List.of("com.example.Patient"), merged.getMatching("Patient"));
+        assertEquals(1, merged.size());
+    }
+
+    @Test
+    void mergeKeepsDistinctCollisions() {
+        ClassIndex a = ClassIndex.of(Map.of("List", List.of("java.util.List")));
+        ClassIndex b = ClassIndex.of(Map.of("List", List.of("com.example.List")));
+        assertEquals(2, ClassIndex.merge(a, b).getMatching("List").size());
+    }
 }
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/ClassMemberIndexTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/ClassMemberIndexTest.java
index a8b3d7823a0..dbfa2d6b9db 100644
--- a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/ClassMemberIndexTest.java
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/ClassMemberIndexTest.java
@@ -19,15 +19,47 @@
 
 package org.drools.completion;
 
+import java.nio.file.Path;
 import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 import org.drools.completion.fixtures.InitProbe;
 import org.junit.jupiter.api.Test;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 class ClassMemberIndexTest {
 
+    /**
+     * A link error can be transient — a dependency jar being rewritten by a
+     * build running alongside the editor is the ordinary case — so it must not
+     * write the name off for the rest of the index's life. Only a genuine "not
+     * on the classpath" answer is worth remembering, because a name that cannot
+     * be loaded also reports "unverifiable" to the unknown-type lint.
+     */
+    @Test
+    void aTransientLinkFailureIsRetried() {
+        String fqcn = "org.drools.completion.fixtures.Pet";
+        AtomicBoolean failNextLoad = new AtomicBoolean(true);
+        ClassLoader flaky = new ClassLoader(getClass().getClassLoader()) {
+            @Override
+            public Class<?> loadClass(String name) throws ClassNotFoundException {
+                if (fqcn.equals(name) && failNextLoad.getAndSet(false)) {
+                    throw new NoClassDefFoundError("jar being rewritten");
+                }
+                return super.loadClass(name);
+            }
+        };
+        ClassMemberIndex index = new ClassMemberIndex(flaky);
+
+        assertTrue(index.membersOf(fqcn).isEmpty(), "the failed attempt yields nothing");
+        assertThat(index.membersOf(fqcn)).as("and the name is not written off").isNotEmpty();
+    }
+
     private final ClassMemberIndex index =
             new ClassMemberIndex(getClass().getClassLoader());
 
@@ -98,7 +130,8 @@ class ClassMemberIndexTest {
     @Test
     void unknownClassYieldsNoMembers() {
         assertThat(index.membersOf("does.not.Exist")).isEmpty();
-        // Negative result is cached and stays consistent.
+        // The failed load is memoized (not the empty result itself); repeated
+        // lookups skip Class.forName but keep returning the same empty list.
         assertThat(index.membersOf("does.not.Exist")).isEmpty();
     }
 
@@ -118,4 +151,84 @@ class ClassMemberIndexTest {
         assertThat(borrowed.loadClass("java.lang.String")).isNotNull();
         ClassMemberIndex.empty().close(); // must not throw
     }
+
+    private JavaMemberSource fakeSource() {
+        return new JavaMemberSource() {
+            public List<Field> membersOf(String fqcn) {
+                return "com.example.Only".equals(fqcn)
+                    ? List.of(new Field("code", "String", null, Field.Origin.FIELD),
+                              new Field("active", "boolean", null, Field.Origin.GETTER))
+                    : List.of();
+            }
+            public Set<String> memberNames(String fqcn) {
+                return "com.example.Only".equals(fqcn) ? Set.of("code", "active") : null;
+            }
+            public List<String> supertypesOf(String fqcn) {
+                // FQCN, per the JavaMemberSource contract: resolvable supertypes only.
+                return "com.example.Only".equals(fqcn) ? List.of("com.example.Base") : List.of();
+            }
+            public List<String> constructorsOf(String fqcn) {
+                return "com.example.Only".equals(fqcn) ? List.of("Only(String)") : List.of();
+            }
+        };
+    }
+
+    @Test
+    void reflectsConstructorsForClasspathType() {
+        ClassMemberIndex idx = new ClassMemberIndex(getClass().getClassLoader());
+        List<String> ctors = idx.constructorsOf("java.lang.String");
+        assertTrue(ctors.stream().anyMatch(c -> c.startsWith("String(")),
+            () -> "ctors=" + ctors);
+    }
+
+    @Test
+    void tagsMemberOrigins() {
+        ClassMemberIndex idx = new ClassMemberIndex(getClass().getClassLoader());
+        // java.awt.Point's getX()/getY()/getLocation() are bean-property getters that
+        // win over the same-named fields x,y (DRL property access binds to bean
+        // properties, so the getter-derived view is the semantically correct one).
+        List<Field> pointMembers = idx.membersOf("java.awt.Point");
+        assertTrue(pointMembers.stream().anyMatch(f -> f.name.equals("x") && f.origin == Field.Origin.GETTER));
+        assertTrue(pointMembers.stream().anyMatch(f -> f.name.equals("location") && f.origin == Field.Origin.GETTER));
+        // Pet.legs has no getter, so it surfaces as a plain FIELD.
+        List<Field> petMembers = idx.membersOf("org.drools.completion.fixtures.Pet");
+        assertTrue(petMembers.stream().anyMatch(f -> f.name.equals("legs") && f.origin == Field.Origin.FIELD));
+    }
+
+    @Test
+    void fallsBackToSourceWhenClassNotLoadable() {
+        ClassMemberIndex idx = new ClassMemberIndex(getClass().getClassLoader());
+        idx.setSourceFallback(fakeSource());
+        // com.example.Only is not on the test classpath -> reflection miss -> fallback
+        assertTrue(idx.membersOf("com.example.Only").stream().anyMatch(f -> f.name.equals("code")));
+        assertEquals(Set.of("code", "active"), idx.memberNames("com.example.Only"));
+        assertEquals(List.of("com.example.Base"), idx.supertypesOf("com.example.Only"));
+        assertEquals(List.of("Only(String)"), idx.constructorsOf("com.example.Only"));
+    }
+
+    @Test
+    void compiledWinsOverSource() {
+        ClassMemberIndex idx = new ClassMemberIndex(getClass().getClassLoader());
+        idx.setSourceFallback(fakeSource());
+        // java.lang.String IS loadable -> fallback never consulted (fake returns empty for it anyway;
+        // assert we get real reflected members, not the fake's)
+        assertTrue(idx.memberNames("java.lang.String").contains("CASE_INSENSITIVE_ORDER"));
+    }
+
+    @Test
+    void memberNamesStillNullWhenNeitherKnows() {
+        ClassMemberIndex idx = new ClassMemberIndex(getClass().getClassLoader());
+        idx.setSourceFallback(fakeSource());
+        assertNull(idx.memberNames("com.nope.Nothing"));
+    }
+
+    @Test
+    void sourceOnlyWorkspaceUsesFallback() {
+        // Zero classpath entries -> the works-before-build scenario. of() must not
+        // hand back the shared empty() singleton, or a later setSourceFallback would
+        // silently no-op.
+        ClassMemberIndex idx = ClassMemberIndex.of(Set.<Path>of());
+        idx.setSourceFallback(fakeSource());
+        assertTrue(idx.membersOf("com.example.Only").stream().anyMatch(f -> f.name.equals("code")));
+    }
 }
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLCompletionHelperTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLCompletionHelperTest.java
index 6adf55bbe26..2a0016b6adf 100644
--- a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLCompletionHelperTest.java
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLCompletionHelperTest.java
@@ -384,6 +384,297 @@ class DRLCompletionHelperTest {
         assertThat(fieldLabels).contains("name", "friendly", "legs");
     }
 
+    /**
+     * A caret after a dot can only be followed by a member of the path's type.
+     * The grammar predicts the constraint operators there (it cannot see the path
+     * is unfinished), so that position is answered from the type walk alone.
+     */
+    @Test
+    void memberCompletionAfterADotOnAPatternField() {
+        String text = """
+                package demo;
+
+                declare Fact
+                  order : int
+                  ref : Fact
+                end
+
+                rule R
+                  when
+                    Fact( ref. )
+                  then
+                end
+                """;
+
+        Position caretPosition = new Position(9, 14); // right after 'ref.'
+        List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+                text, caretPosition, getLanguageClient(), ClassIndex.empty());
+
+        assertThat(result).extracting(CompletionItem::getLabel)
+                .containsExactlyInAnyOrder("order", "ref");
+        assertThat(result).allSatisfy(item ->
+                assertThat(item.getKind()).isEqualTo(CompletionItemKind.Field));
+    }
+
+    /** Mid-edit the constraint is unfinished; the members must still resolve. */
+    @Test
+    void memberCompletionAfterADotWithTheConstraintStillUnclosed() {
+        String text = """
+                package demo;
+
+                declare Fact
+                  order : int
+                  ref : Fact
+                end
+
+                rule R
+                  when
+                    Fact( ref.""";
+
+        Position caretPosition = new Position(9, 14);
+        List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+                text, caretPosition, getLanguageClient(), ClassIndex.empty());
+
+        assertThat(result).extracting(CompletionItem::getLabel)
+                .containsExactlyInAnyOrder("order", "ref");
+    }
+
+    @Test
+    void memberCompletionAfterADotWalksEveryHopOfTheChain() {
+        String text = """
+                package demo;
+
+                declare Inner
+                  depth : int
+                end
+
+                declare Fact
+                  inner : Inner
+                end
+
+                rule R
+                  when
+                    Fact( inner.depth. )
+                  then
+                end
+                """;
+
+        // After 'inner.' — one hop, then after 'inner.depth.' — two hops onto an int.
+        List<CompletionItem> oneHop = DRLCompletionHelper.getCompletionItems(
+                text, new Position(12, 16), getLanguageClient(), ClassIndex.empty());
+        assertThat(oneHop).extracting(CompletionItem::getLabel).containsExactly("depth");
+
+        List<CompletionItem> twoHops = DRLCompletionHelper.getCompletionItems(
+                text, new Position(12, 22), getLanguageClient(), ClassIndex.empty());
+        assertThat(twoHops).as("int has no DRL-visible members").isEmpty();
+    }
+
+    @Test
+    void memberCompletionAfterADotOnABinding() {
+        String text = """
+                package demo;
+
+                declare Fact
+                  order : int
+                end
+
+                rule R
+                  when
+                    $f : Fact( order > 0 )
+                    Fact( order > $f. )
+                  then
+                end
+                """;
+
+        Position caretPosition = new Position(9, 21); // right after '$f.'
+        List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+                text, caretPosition, getLanguageClient(), ClassIndex.empty());
+
+        assertThat(result).extracting(CompletionItem::getLabel).containsExactly("order");
+    }
+
+    /**
+     * Writing the pattern head fully qualified is how an author disambiguates a
+     * simple name that collides on the classpath. So the head has to reach member
+     * resolution still qualified: reduced to its simple name it runs into the very
+     * ambiguity it was written to avoid.
+     */
+    @Test
+    void aFullyQualifiedPatternHeadResolvesWhenItsSimpleNameIsAmbiguous() {
+        String text = """
+                package demo;
+
+                rule R
+                  when
+                    org.drools.completion.fixtures.Pet( name. )
+                  then
+                end
+                """;
+        ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+        // Stands in for the ambiguous case: the host can answer for a qualified
+        // name and refuses a bare one, as it does when two classes share a
+        // simple name.
+        ClasspathTypeMembers.install(n -> n != null && n.indexOf('.') >= 0
+                ? memberIndex.membersOf(n) : List.of());
+        try {
+            // Line 4 is the pattern; the caret sits right after 'name.' at col 45.
+            List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+                    text, new Position(4, 45), getLanguageClient(), ClassIndex.empty(), memberIndex);
+
+            assertThat(result).as("members of the field's type").isNotEmpty();
+        } finally {
+            ClasspathTypeMembers.install(null);
+        }
+    }
+
+    /**
+     * The scan back to the enclosing pattern counts parentheses, so a paren
+     * inside a string literal would unbalance it and find the wrong pattern —
+     * or none. The same mask the binding resolver uses keeps the count honest.
+     */
+    @Test
+    void memberCompletionSurvivesAParenInsideAStringConstraint() {
+        String text = """
+                package demo;
+
+                declare Inner
+                  depth : int
+                end
+
+                declare Fact
+                  note : String
+                  ref : Inner
+                end
+
+                rule R
+                  when
+                    Fact( note == ")", ref. )
+                  then
+                end
+                """;
+
+        int col = text.split("\n")[13].indexOf("ref.") + "ref.".length();
+        List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+                text, new Position(13, col), getLanguageClient(), ClassIndex.empty());
+
+        assertThat(result).extracting(CompletionItem::getLabel).containsExactly("depth");
+    }
+
+    /**
+     * A dot is not only a member access: it also separates the segments of a
+     * qualified name. The head of such a name resolves to no type, so the member
+     * walk finds nothing and must leave the position to the grammar's own
+     * candidates rather than answering with an empty list.
+     */
+    @Test
+    void aDotInAQualifiedNameStillOffersTheGrammarCandidates() {
+        String text = "package demo;\n\nimport com.example.\n";
+
+        Position caretPosition = new Position(2, 19); // right after 'com.example.'
+        List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+                text, caretPosition, getLanguageClient(), ClassIndex.empty());
+
+        assertThat(result).isNotEmpty();
+    }
+
+    @Test
+    void memberCompletionAfterADotOnAClasspathType() {
+        String text = """
+                package demo;
+
+                import org.drools.completion.fixtures.Pet;
+
+                declare Owner
+                  pet : Pet
+                end
+
+                rule R
+                  when
+                    Owner( pet. )
+                  then
+                end
+                """;
+
+        ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+        Position caretPosition = new Position(10, 15); // right after 'pet.'
+        List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+                text, caretPosition, getLanguageClient(), ClassIndex.empty(), memberIndex);
+
+        assertThat(result).extracting(CompletionItem::getLabel)
+                .contains("name", "friendly", "legs");
+    }
+
+    /** A decimal literal being typed is not a member position. */
+    @Test
+    void aTrailingDotOnANumberIsNotAMemberPosition() {
+        String text = """
+                package demo;
+
+                declare Fact
+                  order : int
+                end
+
+                rule R
+                  when
+                    Fact( order > 1. )
+                  then
+                end
+                """;
+
+        List<CompletionItem> result = DRLCompletionHelper.getCompletionItems(
+                text, new Position(9, 20), getLanguageClient(), ClassIndex.empty());
+
+        assertThat(result).extracting(CompletionItem::getKind)
+                .doesNotContain(CompletionItemKind.Field);
+    }
+
+    /**
+     * Rule attributes are legal only in a rule/query header. c3 predicts them at a
+     * statement boundary too, where they cannot be typed.
+     */
+    @Test
+    void statementBoundaryOmitsRuleAttributes() {
+        String text = """
+                package demo;
+
+                rule R
+                  when
+                  then
+                end
+
+                """;
+
+        List<String> labels = DRLCompletionHelper.getCompletionItems(
+                        text, new Position(6, 0), getLanguageClient())
+                .stream().map(CompletionItem::getLabel).toList();
+
+        assertThat(labels).contains("import", "global", "rule", "declare", "function", "query");
+        assertThat(labels).doesNotContain(
+                "salience", "no-loop", "dialect", "ruleflow-group", "agenda-group",
+                "auto-focus", "lock-on-active", "activation-group", "date-effective",
+                "date-expires", "calendars", "timer", "duration", "attributes", "enabled");
+    }
+
+    /** The same attributes stay available where they belong. */
+    @Test
+    void ruleHeaderKeepsOfferingRuleAttributes() {
+        String text = """
+                package demo;
+
+                rule R
+                 \s
+                  when
+                  then
+                end
+                """;
+
+        List<String> labels = DRLCompletionHelper.getCompletionItems(
+                        text, new Position(3, 2), getLanguageClient())
+                .stream().map(CompletionItem::getLabel).toList();
+
+        assertThat(labels).contains("salience", "no-loop", "dialect", "when");
+    }
+
     @Test
     void constraintPositionOmitsPrimitiveKeywordNoise() {
         // Inside a pattern's parens c3 also predicts Java expression starters
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDefinitionHelperTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDefinitionHelperTest.java
index aa7ca421d93..88a450834a9 100644
--- a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDefinitionHelperTest.java
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLDefinitionHelperTest.java
@@ -52,7 +52,7 @@ class DRLDefinitionHelperTest {
         // Caret on "Person" inside the pattern.
         List<Location> defs = DRLDefinitionHelper.findDefinitions(
                 "myDocument", DECLARE_DRL, new Position(8, 6),
-                ClassIndex.empty(), Set.of());
+                ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty());
 
         assertThat(defs).hasSize(1);
         Location loc = defs.get(0);
@@ -88,7 +88,7 @@ class DRLDefinitionHelperTest {
         // Caret on "Pet" in the pattern.
         List<Location> defs = DRLDefinitionHelper.findDefinitions(
                 "myDocument", drl, new Position(6, 5),
-                ClassIndex.empty(), Set.of(module.resolve("target/classes")));
+                ClassIndex.empty(), Set.of(module.resolve("target/classes")), JavaSourceTypeIndex.empty());
 
         assertThat(defs).hasSize(1);
         Location loc = defs.get(0);
@@ -97,6 +97,69 @@ class DRLDefinitionHelperTest {
         assertThat(loc.getRange().getStart().getCharacter()).isEqualTo(13);
     }
 
+    @Test
+    void javaSourceDefinitionFallsBackToSourceIndexBeforeBuild(@TempDir Path srcRoot) throws Exception {
+        // No target/classes at all — the type has never been compiled.
+        Path fooDir = srcRoot.resolve("com/example");
+        Files.createDirectories(fooDir);
+        Path fooJava = fooDir.resolve("Foo.java");
+        Files.writeString(fooJava, "package com.example;\npublic class Foo {\n}\n");
+
+        JavaSourceTypeIndex sourceIndex = JavaSourceTypeIndex.build(Set.of(srcRoot), List.of());
+
+        String drl = """
+                package demo;
+
+                import com.example.Foo;
+
+                rule R
+                  when
+                    Foo( )
+                  then
+                end
+                """;
+
+        // Caret on "Foo" in the pattern (line 6, col 5); buildOutputDirs is
+        // empty so JavaSourceLocator.locate has nothing to find.
+        List<Location> defs = DRLDefinitionHelper.findDefinitions(
+                "myDocument", drl, new Position(6, 5),
+                ClassIndex.empty(), Set.of(), sourceIndex);
+
+        assertThat(defs).hasSize(1);
+        Location loc = defs.get(0);
+        assertThat(loc.getUri()).isEqualTo(fooJava.toUri().toString());
+        assertThat(loc.getRange().getStart().getLine()).isEqualTo(1);
+        assertThat(loc.getRange().getStart().getCharacter()).isEqualTo(13);
+        assertThat(loc.getRange().getEnd().getCharacter()).isEqualTo(16);
+    }
+
+    @Test
+    void javaSourceDefinitionWithEmptySourceIndexYieldsNothing(@TempDir Path srcRoot) throws Exception {
+        Path fooDir = srcRoot.resolve("com/example");
+        Files.createDirectories(fooDir);
+        Files.writeString(fooDir.resolve("Foo.java"), "package com.example;\npublic class Foo {\n}\n");
+
+        String drl = """
+                package demo;
+
+                import com.example.Foo;
+
+                rule R
+                  when
+                    Foo( )
+                  then
+                end
+                """;
+
+        // Same shape as the fallback test, but with an empty source index and
+        // no build output — pre-existing "no navigable location" behavior.
+        List<Location> defs = DRLDefinitionHelper.findDefinitions(
+                "myDocument", drl, new Position(6, 5),
+                ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty());
+
+        assertThat(defs).isEmpty();
+    }
+
     @Test
     void declaredTypeWinsOverJavaSourceOfTheSameName(@TempDir Path module) throws Exception {
         Path classes = module.resolve("target/classes/demo");
@@ -109,7 +172,7 @@ class DRLDefinitionHelperTest {
 
         List<Location> defs = DRLDefinitionHelper.findDefinitions(
                 "myDocument", DECLARE_DRL, new Position(8, 6),
-                ClassIndex.empty(), Set.of(module.resolve("target/classes")));
+                ClassIndex.empty(), Set.of(module.resolve("target/classes")), JavaSourceTypeIndex.empty());
 
         assertThat(defs).hasSize(1);
         assertThat(defs.get(0).getUri()).isEqualTo("myDocument");
@@ -126,7 +189,7 @@ class DRLDefinitionHelperTest {
 
         List<Location> defs = DRLDefinitionHelper.findDefinitions(
                 current.toUri().toString(), drl, new Position(3, 5),
-                ClassIndex.empty(), Set.of());
+                ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty());
 
         assertThat(defs).hasSize(1);
         assertThat(defs.get(0).getUri()).isEqualTo(types.toUri().toString());
@@ -151,7 +214,7 @@ class DRLDefinitionHelperTest {
         // Caret on "Person" inside the LHS // comment (line 6) — not a real use.
         List<Location> defs = DRLDefinitionHelper.findDefinitions(
                 "myDocument", drl, new Position(6, 9),
-                ClassIndex.empty(), Set.of());
+                ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty());
 
         assertThat(defs).isEmpty();
     }
@@ -160,7 +223,7 @@ class DRLDefinitionHelperTest {
     void unknownSymbolYieldsNoDefinitions() {
         List<Location> defs = DRLDefinitionHelper.findDefinitions(
                 "myDocument", DECLARE_DRL, new Position(8, 14),
-                ClassIndex.empty(), Set.of());
+                ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty());
         // "name" is a field, not a definable type in v1.
         assertThat(defs).isEmpty();
     }
@@ -170,14 +233,16 @@ class DRLDefinitionHelperTest {
         // No siblings (non-file URI), so every parser built is for the current doc.
         DRLParsers.resetParseCount();
         DRLDefinitionHelper.findDefinitions(
-                "myDocument", DECLARE_DRL, new Position(8, 6), ClassIndex.empty(), Set.of());
+                "myDocument", DECLARE_DRL, new Position(8, 6), ClassIndex.empty(), Set.of(),
+                JavaSourceTypeIndex.empty());
         assertThat(DRLParsers.parseCount()).isEqualTo(1);
     }
 
     @Test
     void nullTextYieldsNoDefinitions() {
         assertThat(DRLDefinitionHelper.findDefinitions(
-                "myDocument", null, new Position(0, 0), ClassIndex.empty(), Set.of()))
+                "myDocument", null, new Position(0, 0), ClassIndex.empty(), Set.of(),
+                JavaSourceTypeIndex.empty()))
                 .isEmpty();
     }
 }
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLHoverHelperTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLHoverHelperTest.java
index 09279f1a4a1..f4f68478f98 100644
--- a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLHoverHelperTest.java
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLHoverHelperTest.java
@@ -21,6 +21,8 @@ package org.drools.completion;
 
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
 
 import org.eclipse.lsp4j.Hover;
 import org.eclipse.lsp4j.Position;
@@ -177,6 +179,175 @@ class DRLHoverHelperTest {
         assertThat(md).contains("name").contains("friendly").contains("legs");
     }
 
+    @Test
+    void hoverOnDeclareExtendingAJavaClassShowsTheInheritedJavaFields() {
+        String drl = """
+                package demo;
+
+                import org.drools.completion.fixtures.Pet;
+
+                declare SpecialPet extends Pet
+                  nickname : String
+                end
+
+                rule R
+                  when
+                    SpecialPet( )
+                  then
+                end
+                """;
+        ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+        ClasspathTypeMembers.install(name -> "Pet".equals(name)
+                ? memberIndex.membersOf("org.drools.completion.fixtures.Pet")
+                : List.of());
+        try {
+            // Caret on the declare's own name (line 4: "declare SpecialPet extends Pet").
+            Hover hover = DRLHoverHelper.hover(drl, new Position(4, 10),
+                    ClassIndex.empty(), memberIndex, null);
+
+            String md = content(hover);
+            assertThat(md).contains("nickname");
+            // Inherited from the Java supertype, which the declared-type index
+            // cannot describe on its own.
+            assertThat(md).contains("legs").contains("name").contains("friendly");
+        } finally {
+            ClasspathTypeMembers.install(null);
+        }
+    }
+
+    @Test
+    void hoverOnFullyQualifiedTypeNameResolvesTheType() {
+        String drl = """
+                package demo;
+
+                rule R
+                  when
+                    org.drools.completion.fixtures.Pet( )
+                  then
+                end
+                """;
+        ClassIndex classIndex = ClassIndex.of(
+                Map.of("Pet", List.of("org.drools.completion.fixtures.Pet")));
+        ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+
+        // Caret on the "Pet" segment of the qualified name.
+        Hover hover = DRLHoverHelper.hover(drl, new Position(4, 36), classIndex, memberIndex, null);
+
+        String md = content(hover);
+        assertThat(md).contains("org.drools.completion.fixtures.Pet");
+        assertThat(md).contains("name").contains("legs");
+    }
+
+    /**
+     * The package segments of a qualified name resolve to nothing on their own,
+     * so the chain walk cannot start from one. Any segment of the name describes
+     * the type the whole name identifies.
+     */
+    @Test
+    void hoverOnAPackageSegmentOfAQualifiedNameStillDescribesTheType() {
+        String drl = """
+                package demo;
+
+                rule R
+                  when
+                    org.drools.completion.fixtures.Pet( )
+                  then
+                end
+                """;
+        ClassIndex classIndex = ClassIndex.of(
+                Map.of("Pet", List.of("org.drools.completion.fixtures.Pet")));
+        ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+
+        // Caret on "org", the first package segment.
+        Hover hover = DRLHoverHelper.hover(drl, new Position(4, 5), classIndex, memberIndex, null);
+
+        assertThat(content(hover)).contains("org.drools.completion.fixtures.Pet");
+    }
+
+    @Test
+    void hoverOnClasspathTypeShowsFieldsGettersAndConstructorsAsSections() {
+        String drl = """
+                package demo;
+
+                import org.drools.completion.fixtures.Pet;
+
+                rule R
+                  when
+                    Pet( )
+                  then
+                end
+                """;
+        ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+
+        Hover hover = DRLHoverHelper.hover(drl, new Position(6, 5),
+                ClassIndex.empty(), memberIndex, null);
+
+        String md = content(hover);
+        int fieldsAt = md.indexOf("**Fields**");
+        int gettersAt = md.indexOf("**Getters**");
+        int constructorsAt = md.indexOf("**Constructors**");
+        assertThat(fieldsAt).isPositive();
+        assertThat(gettersAt).isGreaterThan(fieldsAt);
+        assertThat(constructorsAt).isGreaterThan(gettersAt);
+        assertThat(md).contains("- legs : int");
+        assertThat(md).contains("- name : String");
+        assertThat(md).contains("- `Pet()`");
+    }
+
+    @Test
+    void hoverOnClasspathTypeWithNoMembersOrConstructorsRendersHeaderOnly() {
+        // java.lang.Math: only static fields/methods, so membersOf is empty,
+        // and its sole constructor is private, so constructorsOf is empty too.
+        String drl = """
+                package demo;
+
+                rule R
+                  when
+                    Math()
+                  then
+                end
+                """;
+        ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+
+        Hover hover = DRLHoverHelper.hover(drl, new Position(4, 4),
+                ClassIndex.empty(), memberIndex, null);
+
+        String md = content(hover);
+        assertThat(md).contains("java.lang.Math");
+        assertThat(md).doesNotContain("**Fields**")
+                .doesNotContain("**Getters**")
+                .doesNotContain("**Constructors**")
+                .doesNotContain("**Constants**");
+    }
+
+    @Test
+    void hoverOnClasspathEnumShowsConstantsSection() {
+        String drl = """
+                package demo;
+
+                import org.drools.completion.fixtures.PetKind;
+
+                rule R
+                  when
+                    PetKind( )
+                  then
+                end
+                """;
+        ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+
+        Hover hover = DRLHoverHelper.hover(drl, new Position(6, 9),
+                ClassIndex.empty(), memberIndex, null);
+
+        String md = content(hover);
+        // PetKind also picks up "declaringClass" as an inherited Enum getter,
+        // so only Fields (no FIELD-origin members exist) is asserted absent.
+        assertThat(md).contains("**Constants**");
+        assertThat(md).contains("- CAT");
+        assertThat(md).contains("- DOG");
+        assertThat(md).doesNotContain("**Fields**");
+        assertThat(md.indexOf("**Constants**")).isLessThan(md.indexOf("**Getters**"));
+    }
+
     private static final String EXTENDS_DRL = """
             package demo;
 
@@ -322,6 +493,86 @@ class DRLHoverHelperTest {
         assertThat(md).doesNotContain("Account");
     }
 
+    @Test
+    void hoverOnDocumentedFunctionShowsItsDocComment() {
+        String drl = """
+                package org.example;
+                /**
+                 * Computes the risk score for a finding.
+                 */
+                function int riskScore(int base) {
+                    return base * 2;
+                }
+                rule R
+                    when
+                    then
+                        int x = riskScore(1);
+                end
+                """;
+        // Caret on "riskScore" in the call on the RHS (line 10, 0-based).
+        Hover hover = DRLHoverHelper.hover(drl, new Position(10, 17),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        assertThat(content(hover)).contains("Computes the risk score for a finding.");
+    }
+
+    @Test
+    void hoverOnDocumentedGlobalShowsItsDocComment() {
+        String drl = """
+                package org.example;
+                /**
+                 * Shared results collector.
+                 */
+                global java.util.List results;
+                rule R
+                    when
+                    then
+                        results.add("x");
+                end
+                """;
+        // Caret on "results" in the RHS usage.
+        Hover hover = DRLHoverHelper.hover(drl, new Position(8, 9),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        assertThat(content(hover)).contains("Shared results collector.");
+    }
+
+    @Test
+    void hoverOnDocumentedQueryShowsItsDocComment() {
+        String drl = """
+                package org.example;
+                declare Person
+                    name : String
+                end
+                /**
+                 * All persons with the given name.
+                 */
+                query personsNamed(String n)
+                    Person(name == n)
+                end
+                """;
+        // Caret on "personsNamed" in the query header.
+        Hover hover = DRLHoverHelper.hover(drl, new Position(7, 8),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        assertThat(content(hover)).contains("All persons with the given name.");
+    }
+
+    @Test
+    void undocumentedFunctionNameYieldsNoDocHover() {
+        String drl = """
+                package org.example;
+                function int plain(int base) {
+                    return base;
+                }
+                """;
+        // Caret on "plain" in the function header; no preceding doc comment.
+        Hover hover = DRLHoverHelper.hover(drl, new Position(1, 14),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        assertThat(hover).isNull();
+    }
+
     @Test
     void hoverOnJavaLangTypeResolvesWithoutImport() {
         // java.lang.Object is implicitly available and has no bean getters, yet
@@ -340,4 +591,442 @@ class DRLHoverHelperTest {
 
         assertThat(content(hover)).contains("java.lang.Object");
     }
+
+    private static final String ENUM_CHAIN_DRL = """
+            package demo;
+
+            declare enum Status
+              ACTIVE, CLOSED;
+            end
+
+            declare Ticket
+              status : Status
+            end
+
+            rule R
+              when
+                Ticket( status == Status.ACTIVE )
+              then
+            end
+            """;
+
+    @Test
+    void hoverOnQualifiedEnumConstantShowsTheEnum() {
+        // Caret on "ACTIVE" in "Status.ACTIVE": line 12 is
+        // `    Ticket( status == Status.ACTIVE )`, "ACTIVE" spans cols 29-34.
+        Hover hover = DRLHoverHelper.hover(ENUM_CHAIN_DRL, new Position(12, 30),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        String md = content(hover);
+        assertThat(md).contains("Status.ACTIVE");
+        assertThat(md).contains("declare enum Status");
+        assertThat(md).contains("CLOSED");
+    }
+
+    @Test
+    void hoverOnEnumQualifierShowsTheEnumItself() {
+        // Caret on "Status" in "Status.ACTIVE" (cols 22-27 of line 12): the
+        // enum's own render, not the constant header.
+        Hover hover = DRLHoverHelper.hover(ENUM_CHAIN_DRL, new Position(12, 23),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        String md = content(hover);
+        assertThat(md).contains("declare enum Status");
+        assertThat(md).doesNotContain("Status.ACTIVE");
+    }
+
+    @Test
+    void hoverOnChainedFieldResolvesThroughTheBinding() {
+        String drl = """
+                package demo;
+
+                declare Order
+                  total : double
+                end
+
+                declare Line
+                  amount : double
+                end
+
+                rule R
+                  when
+                    $o : Order( )
+                    Line( amount > $o.total )
+                  then
+                end
+                """;
+        // Caret on "total" in "$o.total": line 13 is
+        // `    Line( amount > $o.total )`, "total" spans cols 22-26.
+        Hover hover = DRLHoverHelper.hover(drl, new Position(13, 23),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        String md = content(hover);
+        assertThat(md).contains("**total**").contains("double");
+        assertThat(md).contains("Field of `Order`");
+    }
+
+    @Test
+    void hoverMidChainSegmentRendersThatSegment() {
+        String drl = """
+                package demo;
+
+                declare Inner
+                  c : int
+                end
+
+                declare Outer
+                  b : Inner
+                end
+
+                rule R
+                  when
+                    $a : Outer( )
+                    Inner( c > $a.b.c )
+                  then
+                end
+                """;
+        // Caret on "b" in "$a.b.c": line 13 is `    Inner( c > $a.b.c )`,
+        // "b" is col 18. The hover renders b (type Inner, owner Outer), not c.
+        Hover hover = DRLHoverHelper.hover(drl, new Position(13, 18),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        String md = content(hover);
+        assertThat(md).contains("**b**").contains("Inner");
+        assertThat(md).contains("Field of `Outer`");
+        assertThat(md).doesNotContain("**c**").doesNotContain("int");
+    }
+
+    @Test
+    void unresolvableChainYieldsNull() {
+        // `$nope` binds nothing, so the chain must not resolve — and must not
+        // fall back to hovering "age" as a field of the enclosing Person
+        // pattern (here it is $nope's member, not Person's).
+        String drl = """
+                package demo;
+
+                declare Person
+                  age : int
+                end
+
+                rule R
+                  when
+                    Person( age > $nope.age )
+                  then
+                end
+                """;
+        // Caret on "age" in "$nope.age": line 8 is
+        // `    Person( age > $nope.age )`, the chained "age" spans cols 24-26.
+        assertThat(DRLHoverHelper.hover(drl, new Position(8, 25),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null)).isNull();
+    }
+
+    private static final String ACCUMULATE_DRL = """
+            package demo;
+
+            declare Person
+              name : String
+            end
+
+            rule R
+              when
+                accumulate( $p : Person(); $c : count() )
+              then
+            end
+            """;
+
+    @Test
+    void hoverOnAccumulateFunctionShowsResultType() {
+        // Caret on "count" in "$c : count()": line 8 is
+        // `    accumulate( $p : Person(); $c : count() )`, "count" spans
+        // cols 36-40.
+        Hover hover = DRLHoverHelper.hover(ACCUMULATE_DRL, new Position(8, 38),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        String md = content(hover);
+        assertThat(md).contains("count : Long");
+        assertThat(md).contains("accumulate function");
+    }
+
+    private static final String COUNT_FIELD_DRL = """
+            package demo;
+
+            declare Bucket
+              count : int
+            end
+
+            rule R
+              when
+                Bucket( count > 0 )
+              then
+            end
+            """;
+
+    @Test
+    void countOutsideAccumulateIsNotAFunctionHover() {
+        // "count" here is a plain field of Bucket, not an accumulate function:
+        // line 8 is `    Bucket( count > 0 )`, "count" spans cols 12-16. It must
+        // render as a field, never as the accumulate-function hover.
+        Hover hover = DRLHoverHelper.hover(COUNT_FIELD_DRL, new Position(8, 14),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        String md = content(hover);
+        assertThat(md).contains("count").contains("int");
+        assertThat(md).doesNotContain("accumulate function");
+    }
+
+    private static final String COLLECT_ARG_DRL = """
+            package demo;
+
+            declare Person
+              name : String
+            end
+
+            rule R
+              when
+                accumulate( $p : Person(); $l : collectList($p) )
+              then
+            end
+            """;
+
+    @Test
+    void hoverOnAccumulateArgumentResolvesAsTheBinding() {
+        // Caret on "$p" inside "collectList($p)" (line 8, cols 48-49): the
+        // argument must resolve through the binding step, never as the
+        // function itself.
+        Hover hover = DRLHoverHelper.hover(COLLECT_ARG_DRL, new Position(8, 48),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        String md = content(hover);
+        assertThat(md).contains("declare Person");
+        assertThat(md).doesNotContain("accumulate function");
+    }
+
+    private static final String SUM_ARG_DRL = """
+            package demo;
+
+            declare Person
+              weight : int
+            end
+
+            rule R
+              when
+                accumulate( $p : Person(); $t : sum(sum) )
+              then
+            end
+            """;
+
+    @Test
+    void accumulateArgumentTextEqualToTheFunctionNameIsNotAFunctionHover() {
+        // Line 8 is `    accumulate( $p : Person(); $t : sum(sum) )`: the
+        // function identifier spans cols 36-38, the argument cols 40-42. Only
+        // the identifier itself may render the function hover; the argument
+        // resolves as nothing here and must yield no hover at all.
+        Hover hover = DRLHoverHelper.hover(SUM_ARG_DRL, new Position(8, 41),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        assertThat(hover).isNull();
+    }
+
+    private static final String DOCUMENTED_SUM_DRL = """
+            package demo;
+
+            declare Person
+              weight : int
+            end
+
+            /**
+             * Adds two numbers the slow way.
+             */
+            function int sum(int a, int b) {
+                return a + b;
+            }
+
+            rule R
+              when
+                accumulate( Person( $w : weight ); $t : sum($w) )
+              then
+            end
+            """;
+
+    @Test
+    void accumulateUsageOutranksASameNamedDocumentedFunction() {
+        // Line 15 is `    accumulate( Person( $w : weight ); $t : sum($w) )`;
+        // the accumulate identifier "sum" spans cols 44-46. At that position
+        // the name is structurally an accumulate function, so the hover must
+        // show the function's result type — not the doc of the DRL function
+        // that happens to share its name.
+        Hover hover = DRLHoverHelper.hover(DOCUMENTED_SUM_DRL, new Position(15, 45),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        String md = content(hover);
+        assertThat(md).contains("sum : Double").contains("accumulate function");
+        assertThat(md).doesNotContain("slow way");
+    }
+
+    @Test
+    void documentedFunctionStillShowsItsDocOutsideAccumulate() {
+        // Line 9 is `function int sum(int a, int b) {`; "sum" spans cols
+        // 13-15. Outside an accumulate span the doc-comment hover applies.
+        Hover hover = DRLHoverHelper.hover(DOCUMENTED_SUM_DRL, new Position(9, 14),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        assertThat(content(hover)).contains("Adds two numbers the slow way.");
+    }
+
+    @Test
+    void hoverOnAccumulateResultBindingResolvesAsTheBinding() {
+        // Caret on "$c" in "$c : count()" (line 8, cols 31-32): the label
+        // sits inside the accumulateFunction node, but only the function
+        // identifier itself may render the function hover — the label resolves
+        // through the binding step to the function's result type.
+        Hover hover = DRLHoverHelper.hover(ACCUMULATE_DRL, new Position(8, 31),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        String md = content(hover);
+        assertThat(md).contains("Long");
+        assertThat(md).doesNotContain("accumulate function");
+    }
+
+    private static final String BIG_DECIMAL_ACCUMULATE_DRL = """
+            package demo;
+
+            declare Person
+              amount : java.math.BigDecimal
+            end
+
+            rule R
+              when
+                accumulate( Person( $a : amount ); $m : maxBD($a) )
+              then
+            end
+            """;
+
+    @Test
+    void hoverOnABigDecimalAccumulateFunctionShowsItsResultType() {
+        // Line 8 is `    accumulate( Person( $a : amount ); $m : maxBD($a) )`;
+        // the function identifier "maxBD" spans cols 44-48.
+        Hover hover = DRLHoverHelper.hover(BIG_DECIMAL_ACCUMULATE_DRL, new Position(8, 46),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        String md = content(hover);
+        assertThat(md).contains("maxBD : BigDecimal").contains("accumulate function");
+    }
+
+    @Test
+    void hoverOnABindingUsedInTheConsequenceIgnoresAConditionCommentSayingThen() {
+        String drl = """
+                package demo;
+
+                global java.util.List results;
+
+                declare Fact
+                  code : String
+                end
+
+                rule R
+                  when
+                    Fact( $first : code )
+                    // match this and then the other
+                    Fact( $second : code )
+                  then
+                    results.add($second);
+                end
+                """;
+        // Line 14 is `    results.add($second);`; "$second" spans cols 16-22.
+        Hover hover = DRLHoverHelper.hover(drl, new Position(14, 18),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        assertThat(content(hover)).contains("String");
+    }
+
+    private static final String PRIMITIVE_BINDING_DRL = """
+            package demo;
+
+            global java.util.List results;
+
+            declare Fact
+              size : int
+            end
+
+            rule R
+              when
+                Fact( $size : size )
+              then
+                results.add($size);
+            end
+            """;
+
+    @Test
+    void hoverOnAPrimitiveTypedBindingNamesItsType() {
+        // Line 10 is `    Fact( $size : size )`; the binding spans cols 10-14.
+        Hover hover = DRLHoverHelper.hover(PRIMITIVE_BINDING_DRL, new Position(10, 12),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        assertThat(content(hover)).contains("$size").contains("int");
+    }
+
+    @Test
+    void hoverOnAPrimitiveTypedBindingInTheConsequenceNamesItsType() {
+        // Line 12 is `    results.add($size);`; the binding spans cols 16-20.
+        Hover hover = DRLHoverHelper.hover(PRIMITIVE_BINDING_DRL, new Position(12, 18),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        assertThat(content(hover)).contains("$size").contains("int");
+    }
+
+    @Test
+    void aDocumentedFunctionDoesNotShadowAnImportedTypeOfTheSameName() {
+        String drl = """
+                package demo;
+
+                import org.drools.completion.fixtures.Pet;
+
+                /** Counts the pets. */
+                function int Pet(int n) {
+                    return n;
+                }
+
+                rule R
+                  when
+                    Pet( )
+                  then
+                end
+                """;
+        // Line 11 is `    Pet( )`; the pattern head spans cols 4-6.
+        Hover hover = DRLHoverHelper.hover(drl, new Position(11, 5), ClassIndex.empty(),
+                new ClassMemberIndex(getClass().getClassLoader()), null);
+
+        String md = content(hover);
+        assertThat(md).contains("org.drools.completion.fixtures.Pet");
+        assertThat(md).doesNotContain("Counts the pets.");
+    }
+
+    @Test
+    void aDocumentedGlobalDoesNotShadowAFieldOfTheSameName() {
+        String drl = """
+                package demo;
+
+                /** Running order total. */
+                global java.math.BigDecimal total
+
+                declare Order
+                  total : int
+                end
+
+                rule R
+                  when
+                    Order( total > 5 )
+                  then
+                end
+                """;
+        // Line 11 is `    Order( total > 5 )`; "total" spans cols 11-15. The
+        // doc-comment parser maps names document-wide, with no position scoping,
+        // so the global's doc must not answer for the field in the constraint.
+        Hover hover = DRLHoverHelper.hover(drl, new Position(11, 13),
+                ClassIndex.empty(), ClassMemberIndex.empty(), null);
+
+        String md = content(hover);
+        assertThat(md).contains("total").contains("int").contains("Order");
+        assertThat(md).doesNotContain("Running order total.");
+    }
 }
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLInlayHintHelperTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLInlayHintHelperTest.java
index 4cee22ea7bc..1d8a3a5743d 100644
--- a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLInlayHintHelperTest.java
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLInlayHintHelperTest.java
@@ -38,6 +38,37 @@ class DRLInlayHintHelperTest {
         assertThat(DRLInlayHintHelper.getHints(null, null)).isEmpty();
     }
 
+    /**
+     * A declare may extend a Java class, and a binding on one of the supertype's
+     * fields has no type unless the host's member lookup is consulted — the
+     * declared-type index knows nothing beyond the DRL.
+     */
+    @Test
+    void emitsHintForFieldInheritedFromAJavaSupertype() {
+        ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
+        ClasspathTypeMembers.install(name -> "Pet".equals(name)
+                ? memberIndex.membersOf("org.drools.completion.fixtures.Pet")
+                : List.of());
+        try {
+            String drl =
+                "declare SpecialPet extends Pet\n" +
+                "  nickname : String\n" +
+                "end\n" +
+                "rule \"r\" when\n" +
+                "  SpecialPet( $l : legs )\n" +
+                "  SpecialPet( legs == $l )\n" +
+                "then\n" +
+                "end\n";
+
+            List<InlayHint> hints = DRLInlayHintHelper.getHints(drl, null);
+
+            // Both the binding declaration and the later naked usage carry the type.
+            assertThat(hints).filteredOn(h -> ": int".equals(labelOf(h))).hasSize(2);
+        } finally {
+            ClasspathTypeMembers.install(null);
+        }
+    }
+
     @Test
     void emitsAccumulateResultBindingHint() {
         AccumulateFunctionTypes.set(java.util.Map.of("count", "Long"));
@@ -372,6 +403,34 @@ class DRLInlayHintHelperTest {
             .containsExactlyInAnyOrder(": int", ": int");
     }
 
+    @Test
+    void emitsHintsForBindingsAfterAConditionCommentSayingThen() {
+        String drl = """
+                package demo;
+
+                global java.util.List results;
+
+                declare Fact
+                  code : String
+                end
+
+                rule R
+                  when
+                    Fact( $first : code )
+                    // match this and then the other
+                    Fact( $second : code )
+                  then
+                    results.add($second);
+                end
+                """;
+
+        List<InlayHint> hints = DRLInlayHintHelper.getHints(drl, null);
+
+        // Both declarations, plus the consequence usage.
+        assertThat(hints).extracting(DRLInlayHintHelperTest::labelOf)
+            .containsExactlyInAnyOrder(": String", ": String", ": String");
+    }
+
     private static String labelOf(InlayHint hint) {
         Either<String, ?> label = hint.getLabel();
         return label.isLeft() ? label.getLeft() : null;
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLLintHelperTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLLintHelperTest.java
index 9f46834d200..fb9c0fed45b 100644
--- a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLLintHelperTest.java
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLLintHelperTest.java
@@ -31,6 +31,34 @@ import static org.assertj.core.api.Assertions.assertThat;
 
 class DRLLintHelperTest {
 
+    /**
+     * A constant on a type known only from source. Reflection would report it
+     * through Class#getFields, so once compiled the reference verifies; the
+     * source view has to answer the same, or every Type.CONSTANT in the project
+     * is flagged as an unknown member until a build has run.
+     */
+    @Test
+    void aStaticConstantOnASourceOnlyTypeIsNotAnUnknownMember(@org.junit.jupiter.api.io.TempDir java.nio.file.Path dir)
+            throws Exception {
+        java.nio.file.Path src = dir.resolve("src/main/java/com/example");
+        java.nio.file.Files.createDirectories(src);
+        java.nio.file.Files.writeString(src.resolve("NumberFact.java"),
+                "package com.example;\npublic class NumberFact {\n"
+                + "  public static final String ONE_HUNDRED = \"100\";\n}\n");
+
+        ClassMemberIndex memberIndex = ClassMemberIndex.of(java.util.Set.of());
+        memberIndex.setSourceFallback(
+                JavaSourceTypeIndex.build(java.util.Set.of(dir.resolve("src/main/java")), java.util.List.of()));
+
+        String text = "package demo;\n\ndeclare Fact\n  value : String\nend\n\n"
+                + "rule R\n  when\n    Fact( value == NumberFact.ONE_HUNDRED )\n  then\nend\n";
+
+        assertThat(DRLLintHelper.lintUnknownTypes(text, ParsedDrl.of(text).compilationUnit, null,
+                java.util.Map.of(),
+                ClassIndex.of(java.util.Map.of("NumberFact", java.util.List.of("com.example.NumberFact"))),
+                memberIndex, true)).isEmpty();
+    }
+
     @AfterEach
     void clearLintProperties() {
         System.clearProperty("drools.lsp.lint.missingEnd");
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLTypeHierarchyHelperTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLTypeHierarchyHelperTest.java
index 6b04345afd6..41242a2720f 100644
--- a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLTypeHierarchyHelperTest.java
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DRLTypeHierarchyHelperTest.java
@@ -55,7 +55,7 @@ class DRLTypeHierarchyHelperTest {
         // Caret on "Dog" at its declare site (line 6, cols 8..11).
         List<TypeHierarchyItem> items = DRLTypeHierarchyHelper.prepare(
                 HIERARCHY_DRL, new Position(6, 9), "myDocument",
-                Map.of(), ClassIndex.empty(), Set.of());
+                Map.of(), ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty());
 
         assertThat(items).hasSize(1);
         TypeHierarchyItem item = items.get(0);
@@ -93,7 +93,8 @@ class DRLTypeHierarchyHelperTest {
         // Caret on "Pet" in the pattern (line 6, col 5).
         List<TypeHierarchyItem> items = DRLTypeHierarchyHelper.prepare(
                 drl, new Position(6, 5), "myDocument",
-                Map.of(), ClassIndex.empty(), Set.of(module.resolve("target/classes")));
+                Map.of(), ClassIndex.empty(), Set.of(module.resolve("target/classes")),
+                JavaSourceTypeIndex.empty());
 
         assertThat(items).hasSize(1);
         TypeHierarchyItem item = items.get(0);
@@ -103,11 +104,78 @@ class DRLTypeHierarchyHelperTest {
                 .isEqualTo("org.example.Pet");
     }
 
+    @Test
+    void prepareResolvesClasspathTypeToSourceIndexBeforeBuild(@TempDir Path srcRoot) throws Exception {
+        // No target/classes at all — the type has never been compiled.
+        Path petDir = srcRoot.resolve("org/example");
+        Files.createDirectories(petDir);
+        Path petJava = petDir.resolve("Pet.java");
+        Files.writeString(petJava, "package org.example;\npublic class Pet {\n}\n");
+
+        JavaSourceTypeIndex sourceIndex = JavaSourceTypeIndex.build(Set.of(srcRoot), List.of());
+
+        String drl = """
+                package demo;
+
+                import org.example.Pet;
+
+                rule R
+                  when
+                    Pet( )
+                  then
+                end
+                """;
+
+        // Caret on "Pet" in the pattern (line 6, col 5); buildOutputDirs is
+        // empty so JavaSourceLocator.locate has nothing to find.
+        List<TypeHierarchyItem> items = DRLTypeHierarchyHelper.prepare(
+                drl, new Position(6, 5), "myDocument",
+                Map.of(), ClassIndex.empty(), Set.of(), sourceIndex);
+
+        assertThat(items).hasSize(1);
+        TypeHierarchyItem item = items.get(0);
+        assertThat(item.getName()).isEqualTo("Pet");
+        assertThat(item.getKind()).isEqualTo(SymbolKind.Class);
+        assertThat(item.getUri()).isEqualTo(petJava.toUri().toString());
+        assertThat(item.getData()).as("classpath items round-trip their FQCN in data")
+                .isEqualTo("org.example.Pet");
+    }
+
+    @Test
+    void prepareResolvesEnumClasspathTypeToSourceIndexWithEnumKind(@TempDir Path srcRoot) throws Exception {
+        Path dir = srcRoot.resolve("org/example");
+        Files.createDirectories(dir);
+        Path statusJava = dir.resolve("Status.java");
+        Files.writeString(statusJava, "package org.example;\npublic enum Status {\n  ON, OFF\n}\n");
+
+        JavaSourceTypeIndex sourceIndex = JavaSourceTypeIndex.build(Set.of(srcRoot), List.of());
+
+        String drl = """
+                package demo;
+
+                import org.example.Status;
+
+                rule R
+                  when
+                    Status( )
+                  then
+                end
+                """;
+
+        // Caret on "Status" in the pattern (line 6, col 8).
+        List<TypeHierarchyItem> items = DRLTypeHierarchyHelper.prepare(
+                drl, new Position(6, 8), "myDocument",
+                Map.of(), ClassIndex.empty(), Set.of(), sourceIndex);
+
+        assertThat(items).hasSize(1);
+        assertThat(items.get(0).getKind()).isEqualTo(SymbolKind.Enum);
+    }
+
     @Test
     void prepareYieldsNothingForUnknownWord() {
         assertThat(DRLTypeHierarchyHelper.prepare(
                 HIERARCHY_DRL, new Position(3, 3), "myDocument",
-                Map.of(), ClassIndex.empty(), Set.of()))
+                Map.of(), ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty()))
                 .isEmpty();
     }
 
@@ -115,7 +183,7 @@ class DRLTypeHierarchyHelperTest {
     void prepareYieldsNothingForNullText() {
         assertThat(DRLTypeHierarchyHelper.prepare(
                 null, new Position(0, 0), "myDocument",
-                Map.of(), ClassIndex.empty(), Set.of()))
+                Map.of(), ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty()))
                 .isEmpty();
     }
 
@@ -125,11 +193,11 @@ class DRLTypeHierarchyHelperTest {
     void supertypesOfDeclareResolvesExtendsParentInSameDocument() {
         TypeHierarchyItem dog = DRLTypeHierarchyHelper.prepare(
                 HIERARCHY_DRL, new Position(6, 9), "myDocument",
-                Map.of(), ClassIndex.empty(), Set.of()).get(0);
+                Map.of(), ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty()).get(0);
 
         List<TypeHierarchyItem> supers = DRLTypeHierarchyHelper.supertypes(
                 dog, HIERARCHY_DRL, Map.of(), ClassIndex.empty(),
-                ClassMemberIndex.empty(), Set.of());
+                ClassMemberIndex.empty(), Set.of(), JavaSourceTypeIndex.empty());
 
         assertThat(supers).hasSize(1);
         TypeHierarchyItem parent = supers.get(0);
@@ -144,11 +212,11 @@ class DRLTypeHierarchyHelperTest {
     void supertypesOfDeclareWithoutExtendsYieldsNothing() {
         TypeHierarchyItem animal = DRLTypeHierarchyHelper.prepare(
                 HIERARCHY_DRL, new Position(2, 9), "myDocument",
-                Map.of(), ClassIndex.empty(), Set.of()).get(0);
+                Map.of(), ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty()).get(0);
 
         assertThat(DRLTypeHierarchyHelper.supertypes(
                 animal, HIERARCHY_DRL, Map.of(), ClassIndex.empty(),
-                ClassMemberIndex.empty(), Set.of()))
+                ClassMemberIndex.empty(), Set.of(), JavaSourceTypeIndex.empty()))
                 .isEmpty();
     }
 
@@ -174,11 +242,12 @@ class DRLTypeHierarchyHelperTest {
 
         TypeHierarchyItem dog = DRLTypeHierarchyHelper.prepare(
                 drl, new Position(4, 9), "myDocument",
-                Map.of(), ClassIndex.empty(), Set.of(module.resolve("target/classes"))).get(0);
+                Map.of(), ClassIndex.empty(), Set.of(module.resolve("target/classes")),
+                JavaSourceTypeIndex.empty()).get(0);
 
         List<TypeHierarchyItem> supers = DRLTypeHierarchyHelper.supertypes(
                 dog, drl, Map.of(), ClassIndex.empty(), ClassMemberIndex.empty(),
-                Set.of(module.resolve("target/classes")));
+                Set.of(module.resolve("target/classes")), JavaSourceTypeIndex.empty());
 
         assertThat(supers).hasSize(1);
         TypeHierarchyItem parent = supers.get(0);
@@ -187,6 +256,87 @@ class DRLTypeHierarchyHelperTest {
         assertThat(parent.getData()).isEqualTo("org.example.Animal");
     }
 
+    @Test
+    void supertypesOfDeclareResolvesSourceIndexParentBeforeBuild(@TempDir Path srcRoot) throws Exception {
+        // No target/classes at all — the type has never been compiled.
+        Path dir = srcRoot.resolve("org/example");
+        Files.createDirectories(dir);
+        Path animalJava = dir.resolve("Animal.java");
+        Files.writeString(animalJava, "package org.example;\npublic class Animal {\n}\n");
+
+        JavaSourceTypeIndex sourceIndex = JavaSourceTypeIndex.build(Set.of(srcRoot), List.of());
+
+        String drl = """
+                package demo;
+
+                import org.example.Animal;
+
+                declare Dog extends Animal
+                  good : boolean
+                end
+                """;
+
+        TypeHierarchyItem dog = DRLTypeHierarchyHelper.prepare(
+                drl, new Position(4, 9), "myDocument",
+                Map.of(), ClassIndex.empty(), Set.of(), sourceIndex).get(0);
+
+        List<TypeHierarchyItem> supers = DRLTypeHierarchyHelper.supertypes(
+                dog, drl, Map.of(), ClassIndex.empty(), ClassMemberIndex.empty(),
+                Set.of(), sourceIndex);
+
+        assertThat(supers).hasSize(1);
+        TypeHierarchyItem parent = supers.get(0);
+        assertThat(parent.getName()).isEqualTo("Animal");
+        assertThat(parent.getUri()).isEqualTo(animalJava.toUri().toString());
+        assertThat(parent.getData()).isEqualTo("org.example.Animal");
+    }
+
+    @Test
+    void supertypesOfSourceOnlyClasspathItemResolvesCrossPackageParentToFqcn(
+            @TempDir Path srcRoot) throws Exception {
+        // Child and Parent are both source-only (no target/classes anywhere)
+        // and live in different packages, so the parent can only be found by
+        // its unique simple name — the same seam JavaSourceTypeIndex#supertypesOf
+        // must resolve to an FQCN for classpathItem's FQCN-keyed lookups to work.
+        Path childDir = srcRoot.resolve("com/a");
+        Files.createDirectories(childDir);
+        Files.writeString(childDir.resolve("Child.java"),
+                "package com.a;\npublic class Child extends Parent {\n}\n");
+        Path parentDir = srcRoot.resolve("com/b");
+        Files.createDirectories(parentDir);
+        Path parentJava = parentDir.resolve("Parent.java");
+        Files.writeString(parentJava, "package com.b;\npublic class Parent {\n}\n");
+
+        JavaSourceTypeIndex sourceIndex = JavaSourceTypeIndex.build(Set.of(srcRoot), List.of());
+        ClassMemberIndex memberIndex = ClassMemberIndex.of(Set.<Path>of());
+        memberIndex.setSourceFallback(sourceIndex);
+
+        String drl = """
+                package demo;
+
+                import com.a.Child;
+
+                rule R
+                  when
+                    Child( )
+                  then
+                end
+                """;
+
+        TypeHierarchyItem child = DRLTypeHierarchyHelper.prepare(
+                drl, new Position(6, 5), "myDocument",
+                Map.of(), ClassIndex.empty(), Set.of(), sourceIndex).get(0);
+
+        List<TypeHierarchyItem> supers = DRLTypeHierarchyHelper.supertypes(
+                child, drl, Map.of(), ClassIndex.empty(), memberIndex, Set.of(), sourceIndex);
+
+        assertThat(supers).hasSize(1);
+        TypeHierarchyItem parent = supers.get(0);
+        assertThat(parent.getName()).isEqualTo("Parent");
+        assertThat(parent.getUri()).isEqualTo(parentJava.toUri().toString());
+        assertThat(parent.getData()).isEqualTo("com.b.Parent");
+    }
+
     @Test
     void supertypesOfClasspathItemReflectsAncestry() {
         // A classpath item whose FQCN is a JDK type with a clear, stable
@@ -202,7 +352,8 @@ class DRLTypeHierarchyHelperTest {
         ClassMemberIndex memberIndex = new ClassMemberIndex(getClass().getClassLoader());
 
         assertThat(DRLTypeHierarchyHelper.supertypes(
-                arrayList, null, Map.of(), ClassIndex.empty(), memberIndex, Set.of()))
+                arrayList, null, Map.of(), ClassIndex.empty(), memberIndex, Set.of(),
+                JavaSourceTypeIndex.empty()))
                 .isEmpty();
     }
 
@@ -212,7 +363,7 @@ class DRLTypeHierarchyHelperTest {
     void subtypesOfDeclareFindsChildrenInSameDocument() {
         TypeHierarchyItem animal = DRLTypeHierarchyHelper.prepare(
                 HIERARCHY_DRL, new Position(2, 9), "myDocument",
-                Map.of(), ClassIndex.empty(), Set.of()).get(0);
+                Map.of(), ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty()).get(0);
 
         List<TypeHierarchyItem> subs = DRLTypeHierarchyHelper.subtypes(
                 animal, HIERARCHY_DRL, Map.of());
@@ -233,7 +384,7 @@ class DRLTypeHierarchyHelperTest {
         String animalText = Files.readString(animalFile);
         TypeHierarchyItem animal = DRLTypeHierarchyHelper.prepare(
                 animalText, new Position(1, 9), animalFile.toUri().toString(),
-                Map.of(), ClassIndex.empty(), Set.of()).get(0);
+                Map.of(), ClassIndex.empty(), Set.of(), JavaSourceTypeIndex.empty()).get(0);
 
         List<TypeHierarchyItem> subs = DRLTypeHierarchyHelper.subtypes(
                 animal, animalText, Map.of());
diff --git a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/Field.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/FieldTest.java
similarity index 55%
copy from packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/Field.java
copy to packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/FieldTest.java
index be69e1d4006..4d36ba72569 100644
--- a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/Field.java
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/FieldTest.java
@@ -19,26 +19,26 @@
 
 package org.drools.completion;
 
-/** A field, bean property, or enum constant of a {@link DeclaredType} or Java class. */
-public class Field {
+import static org.junit.jupiter.api.Assertions.assertEquals;
 
-    public final String name;
-    public final String type;
-    /**
-     * For enum constants declared with constructor arguments
-     * (e.g. {@code LOW(1)}), the raw argument list as written in source —
-     * without the surrounding parentheses. {@code null} for ordinary fields
-     * and for enum constants without arguments.
-     */
-    public final String args;
+import org.junit.jupiter.api.Test;
 
-    Field(String name, String type) {
-        this(name, type, null);
+class FieldTest {
+
+    @Test
+    void defaultsToFieldOrigin() {
+        Field f = new Field("name", "String");
+        assertEquals(Field.Origin.FIELD, f.origin);
+        assertEquals("name", f.name);
+        assertEquals("String", f.type);
     }
 
-    Field(String name, String type, String args) {
-        this.name = name;
-        this.type = type;
-        this.args = args;
+    @Test
+    void carriesExplicitOrigin() {
+        Field g = new Field("active", "boolean", null, Field.Origin.GETTER);
+        assertEquals(Field.Origin.GETTER, g.origin);
+        Field c = new Field("LOW", "Severity", "1", Field.Origin.ENUM_CONSTANT);
+        assertEquals(Field.Origin.ENUM_CONSTANT, c.origin);
+        assertEquals("1", c.args);
     }
 }
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/JavaSourceTypeIndexTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/JavaSourceTypeIndexTest.java
new file mode 100644
index 00000000000..79cae1b1518
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/JavaSourceTypeIndexTest.java
@@ -0,0 +1,257 @@
+/*
+ * 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.drools.completion;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class JavaSourceTypeIndexTest {
+
+    private Path write(Path root, String rel, String content) throws Exception {
+        Path p = root.resolve(rel);
+        Files.createDirectories(p.getParent());
+        Files.writeString(p, content);
+        return p;
+    }
+
+    @Test
+    void indexesTypesAndMembersFromSourceRoot(@TempDir Path root) throws Exception {
+        Path src = root.resolve("src/main/java");
+        write(src, "com/example/Patient.java",
+            "package com.example;\npublic class Patient { private String name; public String getName(){return name;} }\n");
+        write(src, "com/example/Severity.java",
+            "package com.example;\npublic enum Severity { LOW, HIGH }\n");
+
+        JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of());
+
+        assertTrue(idx.classNames().getOrDefault("Patient", List.of()).contains("com.example.Patient"));
+        assertTrue(idx.classNames().getOrDefault("Severity", List.of()).contains("com.example.Severity"));
+        assertEquals("com.example.Patient", idx.byFqcn("com.example.Patient").fqcn);
+        assertTrue(idx.membersOf("com.example.Patient").stream().anyMatch(f -> f.name.equals("name")));
+        assertTrue(idx.memberNames("com.example.Severity").contains("LOW"));
+        assertNull(idx.memberNames("com.example.DoesNotExist"));
+    }
+
+    /**
+     * A public static field is not a fact property, so it stays out of the
+     * member list — but it is still written as Type.NAME, so it has to be in the
+     * name view. Reflection draws the same line, and the unknown-type lint reads
+     * the name view.
+     */
+    @Test
+    void aStaticFieldIsNameableWithoutBeingAMember(@TempDir Path root) throws Exception {
+        Path src = root.resolve("src/main/java");
+        write(src, "com/example/NumberFact.java",
+            "package com.example;\npublic class NumberFact {\n"
+            + "  public static final String ONE_HUNDRED = \"100\";\n"
+            + "  public String getLabel() { return \"l\"; }\n}\n");
+
+        JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of());
+
+        assertTrue(idx.membersOf("com.example.NumberFact").stream()
+                        .noneMatch(f -> f.name.equals("ONE_HUNDRED")),
+                () -> "not a property: " + idx.membersOf("com.example.NumberFact"));
+        assertTrue(idx.memberNames("com.example.NumberFact").contains("ONE_HUNDRED"),
+                () -> "but nameable: " + idx.memberNames("com.example.NumberFact"));
+        assertTrue(idx.memberNames("com.example.NumberFact").contains("label"));
+    }
+
+    @Test
+    void appliesPackageFilters(@TempDir Path root) throws Exception {
+        Path src = root.resolve("src/main/java");
+        write(src, "com/example/A.java", "package com.example;\npublic class A {}\n");
+        write(src, "org/other/B.java", "package org.other;\npublic class B {}\n");
+
+        JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of("com.example*"));
+        assertTrue(idx.classNames().containsKey("A"));
+        assertTrue(!idx.classNames().containsKey("B"));
+    }
+
+    /**
+     * {@code com.example.*} — the documented form — must cover the package
+     * itself as well as everything under it. Reading it as a bare prefix
+     * excludes exactly the types the author meant to include.
+     */
+    @Test
+    void aDottedStarFilterCoversItsOwnPackage(@TempDir Path root) throws Exception {
+        Path src = root.resolve("src/main/java");
+        write(src, "com/example/A.java", "package com.example;\npublic class A {}\n");
+        write(src, "com/example/model/M.java", "package com.example.model;\npublic class M {}\n");
+        write(src, "com/examplefoo/F.java", "package com.examplefoo;\npublic class F {}\n");
+        write(src, "org/other/B.java", "package org.other;\npublic class B {}\n");
+
+        JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of("com.example.*"));
+
+        assertTrue(idx.classNames().containsKey("A"), "the filtered package's own types");
+        assertTrue(idx.classNames().containsKey("M"), "and its sub-packages");
+        assertTrue(!idx.classNames().containsKey("F"), "but not a package merely sharing the prefix");
+        assertTrue(!idx.classNames().containsKey("B"));
+    }
+
+    @Test
+    void discoverUnionsConventionAndConfigured(@TempDir Path root) throws Exception {
+        Files.createDirectories(root.resolve("module-a/src/main/java"));
+        Files.createDirectories(root.resolve("custom/gen"));
+        List<Path> roots = JavaSourceRoots.discover(root, List.of("custom/gen"));
+        assertTrue(roots.stream().anyMatch(p -> p.endsWith(Path.of("src/main/java"))));
+        assertTrue(roots.stream().anyMatch(p -> p.endsWith(Path.of("custom/gen"))));
+    }
+
+    /**
+     * A configured entry is a literal path, and on Windows a glob character is
+     * not even a legal one. Discovery must skip such an entry and still return
+     * the convention roots, rather than letting the setting abort the caller.
+     */
+    @Test
+    void discoverSkipsAConfiguredEntryThatIsNotAUsablePath(@TempDir Path root) throws Exception {
+        Files.createDirectories(root.resolve("module-a/src/main/java"));
+
+        List<Path> roots = JavaSourceRoots.discover(
+                root, List.of("**/src/main/java", "modules/*/src/main/java", "does/not/exist"));
+
+        assertEquals(1, roots.size(), () -> "expected only the convention root, got " + roots);
+        assertTrue(roots.get(0).endsWith(Path.of("src/main/java")),
+                () -> "expected the convention root, got " + roots.get(0));
+    }
+
+    /**
+     * A configured root that contains a discovered one must collapse to the
+     * outermost: indexing walks recursively, so keeping both parses every file
+     * beneath the inner root twice. The ancestor is kept because it is the
+     * broader instruction — naming {@code src} asks for test sources too.
+     */
+    @Test
+    void discoverCollapsesARootNestedInsideAnother(@TempDir Path root) throws Exception {
+        Files.createDirectories(root.resolve("module-a/src/main/java"));
+        Files.createDirectories(root.resolve("module-a/src/test/java"));
+
+        List<Path> roots = JavaSourceRoots.discover(root, List.of("module-a/src"));
+
+        assertEquals(1, roots.size(), () -> "expected only the outermost root, got " + roots);
+        assertTrue(roots.get(0).endsWith(Path.of("module-a/src")),
+                () -> "expected module-a/src, got " + roots.get(0));
+    }
+
+    @Test
+    void siblingRootsAreBothKept(@TempDir Path root) throws Exception {
+        Files.createDirectories(root.resolve("module-a/src/main/java"));
+        Files.createDirectories(root.resolve("module-b/src/main/java"));
+
+        assertEquals(2, JavaSourceRoots.discover(root, List.of()).size());
+    }
+
+    /** Overlapping roots must not index the same file twice. */
+    @Test
+    void overlappingRootsIndexEachTypeOnce(@TempDir Path root) throws Exception {
+        Path pkg = root.resolve("module-a/src/main/java/com/example");
+        Files.createDirectories(pkg);
+        Files.writeString(pkg.resolve("Order.java"),
+                "package com.example;\npublic class Order { private int id; }\n");
+
+        List<Path> roots = JavaSourceRoots.discover(root, List.of("module-a/src"));
+        JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.copyOf(roots), List.of());
+
+        assertEquals(1, idx.roots().size());
+        assertEquals(List.of("com.example.Order"), idx.classNames().get("Order"));
+    }
+
+    @Test
+    void emptyIsInert() {
+        assertTrue(JavaSourceTypeIndex.empty().classNames().isEmpty());
+        assertNull(JavaSourceTypeIndex.empty().memberNames("x"));
+        assertTrue(JavaSourceTypeIndex.empty().membersOf("x").isEmpty());
+    }
+
+    @Test
+    void membersOfIncludesInheritedFieldsFromSamePackageParent(@TempDir Path root) throws Exception {
+        Path src = root.resolve("src/main/java");
+        write(src, "com/example/Parent.java",
+            "package com.example;\npublic class Parent { public String parentField; }\n");
+        write(src, "com/example/Child.java",
+            "package com.example;\npublic class Child extends Parent { public String childField; }\n");
+
+        JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of());
+
+        assertTrue(idx.membersOf("com.example.Child").stream().anyMatch(f -> f.name.equals("parentField")));
+        assertTrue(idx.memberNames("com.example.Child").contains("parentField"));
+        assertTrue(idx.membersOf("com.example.Parent").stream().noneMatch(f -> f.name.equals("childField")));
+    }
+
+    @Test
+    void membersOfLetsOwnFieldShadowInheritedOne(@TempDir Path root) throws Exception {
+        Path src = root.resolve("src/main/java");
+        write(src, "com/example/Parent.java",
+            "package com.example;\npublic class Parent { public String value; }\n");
+        write(src, "com/example/Child.java",
+            "package com.example;\npublic class Child extends Parent { public int value; }\n");
+
+        JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of());
+
+        List<Field> values = idx.membersOf("com.example.Child").stream()
+                .filter(f -> f.name.equals("value")).toList();
+        assertEquals(1, values.size());
+        assertEquals("int", values.get(0).type);
+    }
+
+    @Test
+    void membersOfResolvesCrossPackageParentByUniqueSimpleName(@TempDir Path root) throws Exception {
+        Path src = root.resolve("src/main/java");
+        write(src, "com/a/Child.java", "package com.a;\npublic class Child extends Base {}\n");
+        write(src, "com/b/Base.java", "package com.b;\npublic class Base { public String baseField; }\n");
+
+        JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of());
+
+        assertTrue(idx.membersOf("com.a.Child").stream().anyMatch(f -> f.name.equals("baseField")));
+    }
+
+    @Test
+    void supertypesOfResolvesCrossPackageParentByUniqueSimpleNameToFqcn(@TempDir Path root) throws Exception {
+        Path src = root.resolve("src/main/java");
+        write(src, "com/a/Child.java", "package com.a;\npublic class Child extends Parent {}\n");
+        write(src, "com/b/Parent.java", "package com.b;\npublic class Parent {}\n");
+
+        JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of());
+
+        assertEquals(List.of("com.b.Parent"), idx.supertypesOf("com.a.Child"));
+    }
+
+    @Test
+    void membersOfTerminatesOnExtendsCycle(@TempDir Path root) throws Exception {
+        Path src = root.resolve("src/main/java");
+        write(src, "com/example/A.java",
+            "package com.example;\npublic class A extends B { public String aField; }\n");
+        write(src, "com/example/B.java",
+            "package com.example;\npublic class B extends A { public String bField; }\n");
+
+        JavaSourceTypeIndex idx = JavaSourceTypeIndex.build(Set.of(src), List.of());
+
+        List<Field> members = idx.membersOf("com.example.A");
+        assertEquals(1, members.stream().filter(f -> f.name.equals("aField")).count());
+        assertEquals(1, members.stream().filter(f -> f.name.equals("bField")).count());
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/JavaSourceTypeParserTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/JavaSourceTypeParserTest.java
new file mode 100644
index 00000000000..d4da542246d
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/JavaSourceTypeParserTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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.drools.completion;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+
+class JavaSourceTypeParserTest {
+
+    private JavaSourceType only(String src) {
+        List<JavaSourceType> ts = JavaSourceTypeParser.parse(src);
+        assertEquals(1, ts.size(), () -> "expected exactly one top-level type, got " + ts.size());
+        return ts.get(0);
+    }
+
+    private Optional<Field> member(JavaSourceType t, String name) {
+        return t.members.stream().filter(f -> f.name.equals(name)).findFirst();
+    }
+
+    /**
+     * Reflection lists instance members: it filters static fields out and its
+     * property scan rejects static methods. A static offered as a fact property
+     * before a build and withdrawn after it is worse than never offering it,
+     * because a rule written against it does not compile.
+     */
+    @Test
+    void staticMembersAreNotFactProperties() {
+        JavaSourceType t = only(
+            "package com.example;\n"
+            + "public class Order {\n"
+            + "  public static final String VERSION = \"1\";\n"
+            + "  public int id;\n"
+            + "  public static String getBuild() { return \"b\"; }\n"
+            + "  public String getCode() { return \"c\"; }\n"
+            + "}\n");
+
+        assertTrue(member(t, "id").isPresent(), () -> "members=" + t.members);
+        assertTrue(member(t, "code").isPresent(), () -> "members=" + t.members);
+        assertTrue(member(t, "VERSION").isEmpty(),
+            () -> "a static field is not a fact property: " + t.members);
+        assertTrue(member(t, "build").isEmpty(),
+            () -> "nor is a static getter: " + t.members);
+    }
+
+    /**
+     * Reflection reports public methods and public constructors only, so the
+     * source view must not offer more than the compiled view will: a member that
+     * appears before a build and vanishes after it is worse than one that never
+     * appeared, and hover's Constructors section would otherwise name a
+     * constructor the author cannot call.
+     */
+    @Test
+    void nonPublicGettersAndConstructorsAreNotMembers() {
+        JavaSourceType t = only(
+            "package com.example;\n"
+            + "public class Order {\n"
+            + "  public int id;\n"
+            + "  private String secret;\n"
+            + "  private String getSecret() { return secret; }\n"
+            + "  public String getCode() { return \"c\"; }\n"
+            + "  private Order() { }\n"
+            + "  public Order(int id) { }\n"
+            + "}\n");
+
+        assertTrue(member(t, "id").isPresent(), () -> "members=" + t.members);
+        assertTrue(member(t, "code").isPresent(), () -> "members=" + t.members);
+        assertTrue(member(t, "secret").isEmpty(),
+            () -> "a non-public getter must not be a member: " + t.members);
+        assertEquals(List.of("Order(int)"), t.constructors);
+    }
+
+    @Test
+    void parsesClassFieldsGettersAndFqcn() {
+        JavaSourceType t = only(
+            "package com.example.model;\n"
+            + "public class Patient {\n"
+            + "  private String name;\n"
+            + "  public int ageYears;\n"
+            + "  public String getName() { return name; }\n"
+            + "  public boolean isActive() { return true; }\n"
+            + "  public Patient(String name, int ageYears) { }\n"
+            + "}\n");
+        assertEquals("com.example.model.Patient", t.fqcn);
+        assertEquals("Patient", t.simpleName);
+        assertEquals(Field.Origin.GETTER, member(t, "name").orElseThrow().origin);
+        assertEquals("int", member(t, "ageYears").orElseThrow().type);
+        assertEquals(Field.Origin.GETTER, member(t, "active").orElseThrow().origin);
+        assertTrue(t.constructors.contains("Patient(String, int)"),
+            () -> "constructors=" + t.constructors);
+    }
+
+    @Test
+    void parsesEnumConstantsWithArgs() {
+        JavaSourceType t = only(
+            "package com.example;\n"
+            + "public enum Severity {\n"
+            + "  LOW(1), HIGH(3);\n"
+            + "  private final int weight;\n"
+            + "  Severity(int weight) { this.weight = weight; }\n"
+            + "  public int getWeight() { return weight; }\n"
+            + "}\n");
+        assertTrue(t.isEnum);
+        Field low = member(t, "LOW").orElseThrow();
+        assertEquals(Field.Origin.ENUM_CONSTANT, low.origin);
+        assertEquals("1", low.args);
+        assertEquals("Severity", low.type);
+        assertEquals(Field.Origin.GETTER, member(t, "weight").orElseThrow().origin);
+    }
+
+    @Test
+    void parsesInterfaceAndRecordNames() {
+        assertEquals("com.example.Repo", only(
+            "package com.example;\npublic interface Repo { String id(); }\n").fqcn);
+        JavaSourceType rec = only(
+            "package com.example;\npublic record Point(int x, int y) { }\n");
+        assertEquals("com.example.Point", rec.fqcn);
+        assertEquals("int", member(rec, "x").orElseThrow().type);
+        assertTrue(rec.constructors.contains("Point(int, int)"),
+            () -> "constructors=" + rec.constructors);
+    }
+
+    @Test
+    void capturesExtendsAndPosition() {
+        JavaSourceType t = only(
+            "package com.example;\n"
+            + "public class Child extends com.example.Parent {\n"
+            + "}\n");
+        assertEquals("Parent", t.extendsSimpleName);
+        assertEquals(1, t.declLine); // 0-based; "public class Child" is line index 1
+    }
+
+    @Test
+    void toleratesGarbageAndDefaultPackage() {
+        assertTrue(JavaSourceTypeParser.parse("").isEmpty());
+        // Garbage must not throw; what it returns is not specified.
+        assertDoesNotThrow(() -> JavaSourceTypeParser.parse("this is not java {{{"));
+        JavaSourceType t = only("public class NoPkg { int x; }");
+        assertEquals("NoPkg", t.fqcn); // default package => fqcn == simpleName
+    }
+}
diff --git a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/LhsBindingResolverTest.java b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/LhsBindingResolverTest.java
new file mode 100644
index 00000000000..d6ab7fdc466
--- /dev/null
+++ b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/LhsBindingResolverTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.drools.completion;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class LhsBindingResolverTest {
+
+    private static final String DECLARE = """
+            package demo;
+
+            global java.util.List results;
+
+            declare Fact
+              code : String
+            end
+
+            """;
+
+    /**
+     * The bindings visible from the consequence, which is where a rule's author
+     * uses them.
+     */
+    private static Map<String, String> bindingsFromConsequence(String lhs) {
+        String drl = DECLARE + "rule R\n  when\n" + lhs
+                + "  then\n    results.add(\"x\");\nend\n";
+        return LhsBindingResolver.resolveAt(drl, drl.indexOf("results.add"),
+                DRLWorkspaceTypeIndex.build(drl, null, Map.of()));
+    }
+
+    @Test
+    void resolvesEveryBindingInTheCondition() {
+        assertThat(bindingsFromConsequence("    Fact( $first : code )\n"
+                + "    Fact( $second : code )\n"))
+                .containsEntry("first", "String")
+                .containsEntry("second", "String");
+    }
+
+    @Test
+    void aLineCommentSayingThenDoesNotHideTheBindingsAfterIt() {
+        assertThat(bindingsFromConsequence("    Fact( $first : code )\n"
+                + "    // match this and then the other\n"
+                + "    Fact( $second : code )\n"))
+                .containsEntry("first", "String")
+                .containsEntry("second", "String");
+    }
+
+    @Test
+    void aBlockCommentSayingThenDoesNotHideTheBindingsAfterIt() {
+        assertThat(bindingsFromConsequence("    Fact( $first : code )\n"
+                + "    /* match this and then\n       the other */\n"
+                + "    Fact( $second : code )\n"))
+                .containsEntry("first", "String")
+                .containsEntry("second", "String");
+    }
+
+    @Test
+    void aStringLiteralSayingThenLeavesItsOwnPatternIntact() {
+        // The literal sits mid-pattern, so truncating there also unbalances the
+        // parentheses and loses the binding declared before it.
+        assertThat(bindingsFromConsequence("    Fact( $first : code, code == \"then\" )\n"
+                + "    Fact( $second : code )\n"))
+                .containsEntry("first", "String")
+                .containsEntry("second", "String");
+    }
+
+    @Test
+    void aStringLiteralSayingEndDoesNotTruncateTheRule() {
+        assertThat(bindingsFromConsequence("    Fact( $first : code, code == \"end\" )\n"
+                + "    Fact( $second : code )\n"))
+                .containsEntry("first", "String")
+                .containsEntry("second", "String");
+    }
+}
diff --git a/packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspDocumentService.java b/packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspDocumentService.java
index f7e6b4eb8f2..4e5b12a9863 100644
--- a/packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspDocumentService.java
+++ b/packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspDocumentService.java
@@ -37,6 +37,8 @@ import java.util.logging.Logger;
 
 import org.drools.completion.ClassIndex;
 import org.drools.completion.ClassMemberIndex;
+import org.drools.completion.ClasspathTypeMembers;
+import org.drools.completion.Field;
 import org.drools.completion.DRLCompletionHelper;
 import org.drools.completion.DRLCodeLensHelper;
 import org.drools.completion.DRLDefinitionHelper;
@@ -49,6 +51,7 @@ import org.drools.completion.DRLHoverHelper;
 import org.drools.completion.DRLInlayHintHelper;
 import org.drools.completion.DRLLintHelper;
 import org.drools.completion.DRLTypeHierarchyHelper;
+import org.drools.completion.JavaSourceTypeIndex;
 import org.drools.drl.parser.antlr4.DRLParserHelper;
 import org.eclipse.lsp4j.CodeAction;
 import org.eclipse.lsp4j.CodeActionKind;
@@ -106,11 +109,54 @@ public class DroolsLspDocumentService implements TextDocumentService {
     private final Map<String, String> sourcesMap = new ConcurrentHashMap<>();
     private volatile ClassIndex classIndex = ClassIndex.empty();
     private volatile ClassMemberIndex classMemberIndex = ClassMemberIndex.empty();
+    private volatile JavaSourceTypeIndex javaSourceIndex = JavaSourceTypeIndex.empty();
 
     private final DroolsLspServer server;
 
     public DroolsLspDocumentService(DroolsLspServer server) {
         this.server = server;
+        // Lets binding resolution describe types the DRL does not declare, so
+        // hover and inlay hints work on Java fact classes. The closure reads the
+        // live indexes on every call, so a rebuilt classpath needs no re-install.
+        ClasspathTypeMembers.install(this::membersOfTypeName);
+    }
+
+    /**
+     * Members of the type named {@code typeName} as written in the DRL, with
+     * inherited members folded in — {@link ClassMemberIndex} reflects over the
+     * full hierarchy, and its source fallback walks the {@code extends} chain,
+     * so a superclass field resolves before and after a build. Empty when the
+     * name is unknown or ambiguous.
+     */
+    private List<Field> membersOfTypeName(String typeName) {
+        if (typeName == null || typeName.isEmpty()) {
+            return Collections.emptyList();
+        }
+        String fqcn = typeName.indexOf('.') >= 0
+                ? typeName
+                : uniqueFqcnForSimpleName(typeName);
+        return fqcn == null ? Collections.emptyList() : classMemberIndex.membersOf(fqcn);
+    }
+
+    /**
+     * The single classpath FQCN whose simple name is {@code simpleName}, or
+     * {@code null} when none or several match.
+     *
+     * <p>Weaker than {@code DRLCompletionHelper.resolveFqcn}, which consults the
+     * document's imports first and so picks the right one of two same-named
+     * classes. The seam carries no import context, so an explicitly imported type
+     * whose simple name collides elsewhere on the classpath resolves to nothing
+     * here, and a {@code declare ... extends} on it loses its inherited members.
+     */
+    private String uniqueFqcnForSimpleName(String simpleName) {
+        String found = null;
+        for (String fqcn : classIndex.forSimpleName(simpleName)) {
+            if (found != null && !found.equals(fqcn)) {
+                return null;
+            }
+            found = fqcn;
+        }
+        return found;
     }
 
     public void setClassIndex(ClassIndex classIndex) {
@@ -121,10 +167,18 @@ public class DroolsLspDocumentService implements TextDocumentService {
         this.classMemberIndex = classMemberIndex;
     }
 
+    public void setJavaSourceIndex(JavaSourceTypeIndex javaSourceIndex) {
+        this.javaSourceIndex = javaSourceIndex;
+    }
+
     ClassIndex getClassIndexForTest() {
         return classIndex;
     }
 
+    ClassMemberIndex getClassMemberIndexForTest() {
+        return classMemberIndex;
+    }
+
     @Override
     public void didOpen(DidOpenTextDocumentParams params) {
         sourcesMap.put(params.getTextDocument().getUri(), params.getTextDocument().getText());
@@ -167,8 +221,13 @@ public class DroolsLspDocumentService implements TextDocumentService {
             return Collections.emptyList();
         }
         Path documentPath = toPath(uri);
+        // Gated on the dependency classpath, not on the class index being
+        // non-empty: source-derived names land in that index before Maven has
+        // resolved anything, and judging a dependency type unknown on that basis
+        // reports every one of them as a typo until the build catches up.
         return DRLLintHelper.lintUnknownTypes(text, parsed.compilationUnit, documentPath,
-                openSiblings(documentPath), classIndex, classMemberIndex, classIndex.size() > 0);
+                openSiblings(documentPath), classIndex, classMemberIndex,
+                server.isDependencyClasspathResolved());
     }
 
     @Override
@@ -366,7 +425,8 @@ public class DroolsLspDocumentService implements TextDocumentService {
             String uri = params.getTextDocument().getUri();
             Path documentPath = toPath(uri);
             return DRLTypeHierarchyHelper.prepare(textForUri(uri), params.getPosition(), uri,
-                    openSiblings(documentPath), classIndex, server.getBuildOutputDirs());
+                    openSiblings(documentPath), classIndex, server.getBuildOutputDirs(),
+                    javaSourceIndex);
         });
     }
 
@@ -383,7 +443,8 @@ public class DroolsLspDocumentService implements TextDocumentService {
             return DRLTypeHierarchyHelper.supertypes(item,
                     classpath ? null : textForUri(item.getUri()),
                     classpath ? Collections.emptyMap() : openSiblings(toPath(item.getUri())),
-                    classIndex, classMemberIndex, server.getBuildOutputDirs());
+                    classIndex, classMemberIndex, server.getBuildOutputDirs(),
+                    javaSourceIndex);
         });
     }
 
@@ -411,7 +472,7 @@ public class DroolsLspDocumentService implements TextDocumentService {
             Path documentPath = toPath(uri);
             List<Location> definitions = attempt(() -> DRLDefinitionHelper.findDefinitions(
                     uri, text, params.getPosition(), classIndex, server.getBuildOutputDirs(),
-                    openSiblings(documentPath)));
+                    javaSourceIndex, openSiblings(documentPath)));
             return Either.forLeft(definitions == null ? List.of() : definitions);
         });
     }
diff --git a/packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspServer.java b/packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspServer.java
index e7e886c1cc7..552618ae32b 100644
--- a/packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspServer.java
+++ b/packages/drools-lsp/drools-lsp-server/src/main/java/org/drools/lsp/server/DroolsLspServer.java
@@ -36,7 +36,10 @@ import java.util.logging.Logger;
 
 import org.drools.completion.ClassIndex;
 import org.drools.completion.ClassMemberIndex;
+import org.drools.completion.ClasspathTypeMembers;
 import org.drools.completion.DRLDeclaredTypeParser;
+import org.drools.completion.JavaSourceRoots;
+import org.drools.completion.JavaSourceTypeIndex;
 import org.drools.completion.WorkspaceSiblingResolver;
 import org.drools.completion.WorkspaceSiblingResolvers;
 import org.eclipse.lsp4j.jsonrpc.Endpoint;
@@ -71,6 +74,22 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
     private volatile Set<Path> buildOutputDirs = Set.of();
     private volatile ClassIndex jarClassIndex = ClassIndex.empty();
     private volatile ClassMemberIndex classMemberIndex = ClassMemberIndex.empty();
+    private volatile JavaSourceTypeIndex javaSourceIndex = JavaSourceTypeIndex.empty();
+
+    /** The LSP workspace root, once {@code initialize} has parsed a usable {@code rootUri}. */
+    private volatile Path workspaceRootPath;
+
+    /** {@code drools.lsp.java.sourcePaths}, parsed once at {@code initialize}. */
+    private volatile List<String> javaSourcePathsSetting = List.of();
+
+    /** {@code drools.lsp.java.packageFilters}, parsed once at {@code initialize}. */
+    private volatile List<String> javaPackageFiltersSetting = List.of();
+
+    /**
+     * Set once the Maven resolve returns, whether or not it found anything. Not
+     * inferred from the class index, which also carries source-derived names.
+     */
+    private volatile boolean dependencyClasspathResolved = false;
 
     /** Tracks whether {@code shutdown} preceded {@code exit} (LSP spec). */
     private volatile boolean shutdownReceived = false;
@@ -99,13 +118,20 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
     }
 
     public void rebuildClassIndex() {
-        Set<Path> dirs = buildOutputDirs;
-        if (dirs.isEmpty() && jarClassIndex.size() == 0) {
-            return;
-        }
         try {
-            ClassIndex outputIndex = ClassIndex.build(dirs);
-            textService.setClassIndex(ClassIndex.merge(jarClassIndex, outputIndex));
+            // Refreshed inside the try: an escaped exception here (e.g. an
+            // unreadable source root) must not silently abort the rebuild
+            // with no log — the debounce executor never inspects the
+            // resulting future.
+            if (workspaceRootPath != null) {
+                refreshJavaSourceIndex(workspaceRootPath);
+            }
+            // Published unconditionally. Having nothing to index is itself a
+            // result the consumers need: the source index reaches them only
+            // through publishClassIndex and swapMemberIndex, so returning early
+            // on an empty workspace would leave them answering from the previous
+            // snapshot — for types whose files have just been deleted.
+            publishClassIndex();
             // Fresh loader so recompiled classes aren't served from the old one's cache.
             swapMemberIndex(ClassMemberIndex.of(classpathEntries));
             // Drop the declared-type parse cache so edited sibling files re-parse
@@ -116,8 +142,59 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
         }
     }
 
+    /**
+     * Recomputes {@link #javaSourceIndex} from {@code root} using the
+     * current {@link #javaSourcePathsSetting}/{@link #javaPackageFiltersSetting}.
+     * The single choke point for the discover+build pair — every site that
+     * (re)builds the source index (initialize's fast path,
+     * {@link #rebuildClassIndex}, the test hook) calls this rather than
+     * repeating it inline.
+     */
+    private void refreshJavaSourceIndex(Path root) {
+        List<Path> roots = JavaSourceRoots.discover(root, javaSourcePathsSetting);
+        javaSourceIndex = JavaSourceTypeIndex.build(new LinkedHashSet<>(roots), javaPackageFiltersSetting);
+    }
+
+    /**
+     * Publishes the merged class index — compiled classpath classes, this
+     * workspace's own build output, and Java-source-derived type names —
+     * to the document service. The single choke point for every publish
+     * site (initialize's fast/post-mvn phases, {@link #rebuildClassIndex}),
+     * so source names are never missing from one path but not another.
+     * Nudges the client to re-pull diagnostics afterward (same pull-model
+     * nudge the build-diagnostics tier uses) so unknown-type squiggles
+     * against a type this publish just resolved clear promptly rather than
+     * waiting for the next edit/save.
+     */
+    private void publishClassIndex() {
+        ClassIndex outputIndex = ClassIndex.build(buildOutputDirs);
+        ClassIndex merged = ClassIndex.merge(ClassIndex.merge(jarClassIndex, outputIndex),
+                ClassIndex.of(javaSourceIndex.classNames()));
+        textService.setClassIndex(merged);
+        textService.setJavaSourceIndex(javaSourceIndex);
+        if (client != null) {
+            try {
+                // Pull model: nudge the client to re-request diagnostics, same
+                // as the build-diagnostics tier's refresh — some clients (and
+                // test doubles) don't implement this LSP4J capability method.
+                client.refreshDiagnostics();
+            } catch (Exception e) {
+                logger.log(Level.FINE, "Failed to refresh diagnostics after publishing class index", e);
+            }
+        }
+    }
+
+    /**
+     * The unknown-type lint waits on this: it cannot call a type unknown while
+     * part of the classpath is still missing.
+     */
+    boolean isDependencyClasspathResolved() {
+        return dependencyClasspathResolved;
+    }
+
     private void setResolvedClasspath(Set<Path> entries) {
         this.classpathEntries = entries;
+        this.dependencyClasspathResolved = true;
         this.buildOutputDirs = filterDirectories(entries);
         Set<Path> jars = filterJars(entries);
         this.jarClassIndex = jars.isEmpty() ? ClassIndex.empty() : ClassIndex.build(jars);
@@ -138,6 +215,7 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
     private synchronized void swapMemberIndex(ClassMemberIndex next) {
         ClassMemberIndex previous = this.classMemberIndex;
         this.classMemberIndex = next;
+        next.setSourceFallback(javaSourceIndex);
         textService.setClassMemberIndex(next);
         if (previous != next) {
             try {
@@ -152,6 +230,45 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
         setResolvedClasspath(entries);
     }
 
+    /**
+     * Test-only entry point: runs the same fast-path source-typing steps
+     * {@code initialize} runs before the mvn phase (re-read the
+     * {@code drools.lsp.java.*} settings, refresh the source index via
+     * {@link #refreshJavaSourceIndex}, install it as the member-index
+     * fallback, publish the merged class index) without the async plumbing
+     * or a real LSP client — so a test exercising the settings path doesn't
+     * need to duplicate {@code initialize}'s sys-prop parsing.
+     */
+    void initializeJavaSourceTypingForTest(Path workspaceRoot) {
+        this.workspaceRootPath = workspaceRoot;
+        this.javaSourcePathsSetting = parseSemicolonSetting(
+                System.getProperty("drools.lsp.java.sourcePaths"));
+        this.javaPackageFiltersSetting = parseSemicolonSetting(
+                System.getProperty("drools.lsp.java.packageFilters"));
+        refreshJavaSourceIndex(workspaceRoot);
+        swapMemberIndex(ClassMemberIndex.of(classpathEntries));
+        publishClassIndex();
+    }
+
+    /**
+     * Splits a {@code drools.lsp.java.*} system property on {@code ;},
+     * trimming and dropping empty entries. {@code null}/blank yields an
+     * empty list.
+     */
+    private static List<String> parseSemicolonSetting(String raw) {
+        if (raw == null || raw.isBlank()) {
+            return List.of();
+        }
+        List<String> out = new ArrayList<>();
+        for (String part : raw.split(";")) {
+            String trimmed = part.trim();
+            if (!trimmed.isEmpty()) {
+                out.add(trimmed);
+            }
+        }
+        return out;
+    }
+
     private static Set<Path> filterDirectories(Set<Path> entries) {
         Set<Path> dirs = new LinkedHashSet<>();
         for (Path entry : entries) {
@@ -228,6 +345,20 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
         if (rootUri != null) {
             WorkspaceSiblingResolver resolver = WorkspaceSiblingResolvers.active();
 
+            // Java source typing needs the root and its settings before either
+            // async block runs: a rebuild triggered by a watch event reads them
+            // to refresh the source index. Guarded, because a non-file rootUri
+            // must not fail initialize.
+            try {
+                this.workspaceRootPath = Paths.get(URI.create(rootUri));
+            } catch (Exception e) {
+                logger.log(Level.WARNING, "Failed to read the workspace root for Java source typing", e);
+            }
+            this.javaSourcePathsSetting = parseSemicolonSetting(
+                    System.getProperty("drools.lsp.java.sourcePaths"));
+            this.javaPackageFiltersSetting = parseSemicolonSetting(
+                    System.getProperty("drools.lsp.java.packageFilters"));
+
             // Applied before returning, so that a notification the client sends
             // once initialize completes is necessarily the newer value. Deferring
             // these would let a startup value captured here land afterwards and
@@ -259,6 +390,18 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
                 try {
                     Path rootPath = Paths.get(URI.create(rootUri));
 
+                    // Build the Java-source type index first. Like the build-output
+                    // scan below, this only touches the filesystem (no mvn), so
+                    // source-derived type and member data is available within
+                    // milliseconds — the works-before-build case for a fresh
+                    // checkout with no compiled output yet.
+                    refreshJavaSourceIndex(rootPath);
+                    // classpathEntries is still Set.of() here (setResolvedClasspath
+                    // hasn't run yet) — of() yields a loader-less but
+                    // fallback-capable index, so member data from sources works
+                    // even before the classpath resolves below.
+                    swapMemberIndex(ClassMemberIndex.of(classpathEntries));
+
                     // Resolve against the configured custom POM root(s) when set,
                     // otherwise the workspace root.
                     String pomPathProp = System.getProperty("drools.lsp.maven.pomPath");
@@ -281,7 +424,7 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
                         outputDirs.addAll(MavenClasspathResolver.resolveBuildOutputDirs(mavenRoot));
                     }
                     buildOutputDirs = outputDirs;
-                    textService.setClassIndex(ClassIndex.build(outputDirs));
+                    publishClassIndex();
 
                     // Then resolve the full classpath (dependency JARs via mvn) and
                     // merge, so member hover and field completion over dependencies
@@ -291,8 +434,7 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
                         resolved.addAll(MavenClasspathResolver.resolve(mavenRoot));
                     }
                     setResolvedClasspath(resolved);
-                    ClassIndex outputIndex = ClassIndex.build(buildOutputDirs);
-                    textService.setClassIndex(ClassIndex.merge(jarClassIndex, outputIndex));
+                    publishClassIndex();
                 } catch (Exception e) {
                     logger.log(Level.WARNING, "Failed to build class index at startup", e);
                 }
@@ -478,6 +620,10 @@ public class DroolsLspServer implements LanguageServer, LanguageClientAware {
             logger.log(Level.FINE, "Failed to close class member index on shutdown", e);
         }
         DRLDeclaredTypeParser.clearCache();
+        JavaSourceTypeIndex.clearCache();
+        // The lookup closes over the document service, so leaving it installed
+        // keeps a stopped server — and its indexes — reachable and answering.
+        ClasspathTypeMembers.install(null);
         return CompletableFuture.completedFuture(null);
     }
 
diff --git a/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/DroolsLspDocumentServiceTest.java b/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/DroolsLspDocumentServiceTest.java
index ad63f509cdd..187b89ffc21 100644
--- a/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/DroolsLspDocumentServiceTest.java
+++ b/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/DroolsLspDocumentServiceTest.java
@@ -487,14 +487,39 @@ class DroolsLspDocumentServiceTest {
                 .noneSatisfy(d -> assertThat(d.getSource()).isEqualTo("drools-type"));
     }
 
+    /**
+     * A source-only workspace before Maven has run: the class index carries
+     * Java-source-derived names, so it is not empty, but nothing is known about
+     * the dependencies. Judging a type unknown on that basis reports every
+     * dependency type as a typo, so the pass waits.
+     */
+    @Test
+    void unknownTypeLintWaitsForTheDependencyClasspath(@TempDir Path dir) throws Exception {
+        Path classFile = dir.resolve("com/example/Marker.class");
+        Files.createDirectories(classFile.getParent());
+        Files.createFile(classFile);
+
+        DroolsLspServer server = TestHelperMethods.getDroolsLspServerForDocument(TYPO_DRL);
+        DroolsLspDocumentService service = server.getTextDocumentService();
+        service.setClassIndex(ClassIndex.build(Set.of(dir)));
+
+        assertThat(service.validate("myDocument"))
+                .noneSatisfy(d -> assertThat(d.getSource()).isEqualTo("drools-type"));
+    }
+
     @Test
     void unknownTypeLintReportsTypoOnceClasspathResolved(@TempDir Path dir) throws Exception {
         Path classFile = dir.resolve("com/example/Marker.class");
         Files.createDirectories(classFile.getParent());
         Files.createFile(classFile);
 
-        DroolsLspDocumentService service = getDroolsLspDocumentService(TYPO_DRL);
-        service.setClassIndex(ClassIndex.build(Set.of(dir))); // classpath now "resolved"
+        // Readiness is the dependency classpath having resolved, which is what
+        // the lint waits for — a populated class index no longer implies it,
+        // since source-derived names reach that index before Maven runs.
+        DroolsLspServer server = TestHelperMethods.getDroolsLspServerForDocument(TYPO_DRL);
+        DroolsLspDocumentService service = server.getTextDocumentService();
+        service.setClassIndex(ClassIndex.build(Set.of(dir)));
+        server.setClasspathEntriesForTest(Set.of(dir));
 
         List<Diagnostic> diags = service.validate("myDocument");
         assertThat(diags).anySatisfy(d -> {
diff --git a/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/JavaSourceTypingServerTest.java b/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/JavaSourceTypingServerTest.java
new file mode 100644
index 00000000000..a2527888c3b
--- /dev/null
+++ b/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/JavaSourceTypingServerTest.java
@@ -0,0 +1,183 @@
+/*
+ * 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.drools.lsp.server;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+
+import org.drools.completion.ClassIndex;
+import org.drools.completion.ClassMemberIndex;
+import org.drools.completion.Field;
+import org.drools.completion.LhsBindingResolver;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A fresh checkout with only {@code .java} sources (no {@code target/classes},
+ * no {@code mvn}) still flips the classpath gate and surfaces member data,
+ * because the server merges Java source-derived type names into the
+ * published {@link ClassIndex} and installs the source index as the {@link
+ * ClassMemberIndex} fallback.
+ */
+class JavaSourceTypingServerTest {
+
+    @TempDir
+    Path tempDir;
+
+    @Test
+    void sourceOnlyWorkspaceFlipsGateAndExposesSourceMembers() throws IOException {
+        Path srcDir = tempDir.resolve("src/main/java/com/example");
+        Files.createDirectories(srcDir);
+        Files.writeString(srcDir.resolve("Foo.java"), """
+                package com.example;
+
+                public class Foo {
+                    private String code;
+
+                    public String getCode() {
+                        return code;
+                    }
+                }
+                """);
+
+        DroolsLspServer server = TestHelperMethods.getDroolsLspServerForDocument("");
+        server.initializeJavaSourceTypingForTest(tempDir);
+
+        ClassIndex classIndex = server.getTextDocumentService().getClassIndexForTest();
+        assertThat(classIndex.size()).isGreaterThan(0);
+        assertThat(classIndex.getMatching("Foo")).contains("com.example.Foo");
+
+        ClassMemberIndex memberIndex = server.getTextDocumentService().getClassMemberIndexForTest();
+        List<Field> members = memberIndex.membersOf("com.example.Foo");
+        assertThat(members).anyMatch(f -> f.name.equals("code"));
+    }
+
+    /**
+     * When the last indexed source disappears, the refreshed — now empty — index
+     * still has to reach its consumers. The rebuild's early return is taken in
+     * exactly this state (no build output, no jars, nothing left to index), and
+     * skipping the publish there leaves definition, type hierarchy and member
+     * lookup answering for a type that no longer exists.
+     */
+    @Test
+    void removingTheLastSourceClearsWhatTheConsumersSee() throws IOException {
+        Path srcDir = tempDir.resolve("src/main/java/com/example");
+        Files.createDirectories(srcDir);
+        Path foo = srcDir.resolve("Foo.java");
+        Files.writeString(foo, """
+                package com.example;
+
+                public class Foo {
+                    public String getCode() {
+                        return "c";
+                    }
+                }
+                """);
+
+        DroolsLspServer server = TestHelperMethods.getDroolsLspServerForDocument("");
+        server.initializeJavaSourceTypingForTest(tempDir);
+        assertThat(server.getTextDocumentService().getClassMemberIndexForTest()
+                .membersOf("com.example.Foo")).isNotEmpty();
+
+        Files.delete(foo);
+        server.rebuildClassIndex();
+
+        assertThat(server.getTextDocumentService().getClassMemberIndexForTest()
+                .membersOf("com.example.Foo"))
+                .as("a deleted type must stop answering")
+                .isEmpty();
+        assertThat(server.getTextDocumentService().getClassIndexForTest().size())
+                .as("and stop being offered")
+                .isZero();
+    }
+
+    /**
+     * The classpath-members seam the document service installs is JVM-wide
+     * static state, and shutdown already releases this server's other global
+     * state — the member index's loader, and both parse caches. The seam has to
+     * go with them, or a stopped server keeps answering through a closure that
+     * pins it.
+     */
+    @Test
+    void shutdownReleasesTheClasspathMembersSeam() throws IOException {
+        Path srcDir = tempDir.resolve("src/main/java/com/example");
+        Files.createDirectories(srcDir);
+        Files.writeString(srcDir.resolve("Foo.java"), """
+                package com.example;
+
+                public class Foo {
+                    public String getCode() {
+                        return "c";
+                    }
+                }
+                """);
+
+        DroolsLspServer server = TestHelperMethods.getDroolsLspServerForDocument("");
+        server.initializeJavaSourceTypingForTest(tempDir);
+
+        String drl = "rule R\n  when\n    Foo( $c : code )\n  then\nend\n";
+        int caret = drl.indexOf("$c");
+        assertThat(LhsBindingResolver.resolveAt(drl, caret, Map.of()))
+                .as("the seam answers while the server is up")
+                .containsEntry("c", "String");
+
+        server.shutdown().join();
+
+        assertThat(LhsBindingResolver.resolveAt(drl, caret, Map.of()))
+                .as("and not after it has stopped")
+                .doesNotContainKey("c");
+    }
+
+    @Test
+    void packageFiltersSettingExcludesNonMatchingPackage() throws IOException {
+        Path srcDir = tempDir.resolve("src/main/java/com/example");
+        Files.createDirectories(srcDir);
+        Files.writeString(srcDir.resolve("Foo.java"), """
+                package com.example;
+
+                public class Foo {
+                    private String code;
+
+                    public String getCode() {
+                        return code;
+                    }
+                }
+                """);
+
+        System.setProperty("drools.lsp.java.packageFilters", "org.other*");
+        try {
+            DroolsLspServer server = TestHelperMethods.getDroolsLspServerForDocument("");
+            server.initializeJavaSourceTypingForTest(tempDir);
+
+            ClassIndex classIndex = server.getTextDocumentService().getClassIndexForTest();
+            assertThat(classIndex.getMatching("Foo")).doesNotContain("com.example.Foo");
+
+            ClassMemberIndex memberIndex = server.getTextDocumentService().getClassMemberIndexForTest();
+            assertThat(memberIndex.membersOf("com.example.Foo")).isEmpty();
+        } finally {
+            System.clearProperty("drools.lsp.java.packageFilters");
+        }
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@kie.apache.org
For additional commands, e-mail: commits-help@kie.apache.org