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 477f5d81132 incubator-kie-tools#4006: [drl-vscode-extension] 
deprecated syntax lint (#4007)
477f5d81132 is described below

commit 477f5d81132dd77ed43b63ba6426891caf6390e3
Author: Daniel Clark <[email protected]>
AuthorDate: Mon Oct 19 09:26:42 2026 +0200

    incubator-kie-tools#4006: [drl-vscode-extension] deprecated syntax lint 
(#4007)
    
    Co-authored-by: Claude Fable 5.1 <[email protected]>
---
 packages/drl-vscode-extension/README.md            |  64 ++--
 packages/drl-vscode-extension/package.json         |  12 +
 packages/drl-vscode-extension/src/extension.ts     |   1 +
 .../drl-vscode-extension/syntaxes/drl.tmLanguage   |  12 +-
 .../org/drools/completion/CustomOperators.java     | 140 +++++++
 .../org/drools/completion/DRLDiagnosticHelper.java |  14 +-
 .../java/org/drools/completion/DRLLintHelper.java  |   5 +-
 .../drools/completion/DeprecatedConstructs.java    | 356 ++++++++++++++++++
 .../org/drools/completion/CustomOperatorsTest.java | 104 ++++++
 .../completion/DeprecatedConstructsTest.java       | 406 +++++++++++++++++++++
 .../lsp/server/DroolsLspDocumentService.java       |  19 +-
 .../org/drools/lsp/server/DroolsLspServer.java     |  41 ++-
 .../lsp/server/DroolsLspDocumentServiceTest.java   |  41 +++
 .../org/drools/lsp/server/DroolsLspServerTest.java |  57 +++
 14 files changed, 1234 insertions(+), 38 deletions(-)

diff --git a/packages/drl-vscode-extension/README.md 
b/packages/drl-vscode-extension/README.md
index bfda8bdeb68..cfacc26186d 100644
--- a/packages/drl-vscode-extension/README.md
+++ b/packages/drl-vscode-extension/README.md
@@ -49,6 +49,7 @@ Language support for [DRL (Drools Rule 
Language)](https://kie.apache.org/docs/10
 - Lint diagnostics (missing `end`, missing separators, unbalanced parentheses, 
etc.)
 - Unknown-type lint with typo quick-fix for DRL-declared types
 - Compile diagnostics from the project's own Drools engine on save (see 
Compile Diagnostics)
+- Deprecated-syntax lint for constructs the DRL10 parser no longer supports, 
with migration quick-fixes
 
 ### Information
 
@@ -222,29 +223,30 @@ Saving a `.drl` file compiles it together with the other 
files of its group (see
 
 ## Extension Settings
 
-| Setting                                     | Default    | Description       
                                              |
-| ------------------------------------------- | ---------- | 
--------------------------------------------------------------- |
-| `drools.lsp.logLevel`                       | `INFO`     | Server-side log 
level                                           |
-| `drools.lsp.compile.onSave`                 | `true`     | Compile the saved 
file's group with the project's Drools engine |
-| `drools.lsp.compile.timeoutSeconds`         | `120`      | Seconds one 
compile may run before it is reported as failed     |
-| `drools.lsp.grouping`                       | `{}`       | DRL file 
grouping, declared inline (see above)                  |
-| `drools.lsp.lint.missingEnd`                | `warning`  | Severity for 
missing `end` keyword                              |
-| `drools.lsp.lint.missingSeparator`          | `warning`  | Severity for 
missing constraint separator                       |
-| `drools.lsp.lint.missingSemicolon`          | `warning`  | Severity for 
missing semicolon in consequence                   |
-| `drools.lsp.lint.unbalancedParens`          | `warning`  | Severity for 
unbalanced parentheses                             |
-| `drools.lsp.lint.unknownTypes`              | `warning`  | Severity for 
unrecognized type references                       |
-| `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   |
-| `drools.lsp.formatter.lineLength`           | `110`      | Formatter wrap 
trigger (see Formatting)                         |
-| `drools.lsp.formatter.lineEndings`          | `preserve` | `preserve`, `lf` 
or `crlf`                                      |
-| `drools.lsp.formatter.parenPadding`         | `true`     | Space inside 
non-empty parentheses                              |
-| `drools.lsp.formatter.bindingColonSpace`    | `false`    | Space before a 
binding's or declare field's colon               |
-| `drools.lsp.formatter.normalizeTerminators` | `true`     | Normalize 
`import`/`global` `;` and the accumulate separator    |
-| `drools.lsp.formatter.alignDeclarations`    | `true`     | Column-align 
`declare` fields and enum constants                |
-| `drools.lsp.formatter.headerMetadata`       | `indented` | `indented`, 
`flush` or `inline` header annotations/attributes   |
+| Setting                                     | Default    | Description       
                                               |
+| ------------------------------------------- | ---------- | 
---------------------------------------------------------------- |
+| `drools.lsp.logLevel`                       | `INFO`     | Server-side log 
level                                            |
+| `drools.lsp.compile.onSave`                 | `true`     | Compile the saved 
file's group with the project's Drools engine  |
+| `drools.lsp.compile.timeoutSeconds`         | `120`      | Seconds one 
compile may run before it is reported as failed      |
+| `drools.lsp.grouping`                       | `{}`       | DRL file 
grouping, declared inline (see above)                   |
+| `drools.lsp.lint.missingEnd`                | `warning`  | Severity for 
missing `end` keyword                               |
+| `drools.lsp.lint.missingSeparator`          | `warning`  | Severity for 
missing constraint separator                        |
+| `drools.lsp.lint.missingSemicolon`          | `warning`  | Severity for 
missing semicolon in consequence                    |
+| `drools.lsp.lint.unbalancedParens`          | `warning`  | Severity for 
unbalanced parentheses                              |
+| `drools.lsp.lint.unknownTypes`              | `warning`  | Severity for 
unrecognized type references                        |
+| `drools.lsp.lint.mvelPropertyAccess`        | `off`      | Hint to prefer 
property-access style over getter calls in LHS    |
+| `drools.lsp.lint.deprecated`                | `warning`  | Severity for 
syntax deprecated with the DRL10 parser (see below) |
+| `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    |
+| `drools.lsp.formatter.lineLength`           | `110`      | Formatter wrap 
trigger (see Formatting)                          |
+| `drools.lsp.formatter.lineEndings`          | `preserve` | `preserve`, `lf` 
or `crlf`                                       |
+| `drools.lsp.formatter.parenPadding`         | `true`     | Space inside 
non-empty parentheses                               |
+| `drools.lsp.formatter.bindingColonSpace`    | `false`    | Space before a 
binding's or declare field's colon                |
+| `drools.lsp.formatter.normalizeTerminators` | `true`     | Normalize 
`import`/`global` `;` and the accumulate separator     |
+| `drools.lsp.formatter.alignDeclarations`    | `true`     | Column-align 
`declare` fields and enum constants                 |
+| `drools.lsp.formatter.headerMetadata`       | `indented` | `indented`, 
`flush` or `inline` header annotations/attributes    |
 
 All lint settings accept: `off`, `hint`, `info`, `warning`, `error`.
 
@@ -259,6 +261,22 @@ 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.
 
+## Deprecated syntax
+
+Drools 10 ships two DRL parsers: the legacy `DRL6` parser it compiles with by 
default, and the ANTLR4 `DRL10` parser, enabled with 
`-Ddrools.drl.antlr4.parser.enabled=true`, which accepts a slimmer syntax 
([apache/incubator-kie#6220](https://github.com/apache/incubator-kie/issues/6220)).
 The language server parses with the `DRL10` grammar, so a rule set that still 
uses the dropped syntax shows `no viable alternative` errors where the engine 
merely logs a deprecation. The lint names the c [...]
+
+| Construct                          | Example                   | Quick fix   
            |
+| ---------------------------------- | ------------------------- | 
----------------------- |
+| Half constraint                    | `age > 18 && < 65`        | Repeat the 
left operand |
+| Patterns joined with `&&`/`\|\|`   | `not( A( ) && B( ) )`     | `and` / 
`or`            |
+| Annotation after an LHS `and`/`or` | `A( ) or @Annot B( )`     | none — move 
or drop it  |
+| `agenda-group`                     | `agenda-group "g"`        | 
`ruleflow-group`        |
+| Custom operator without its prefix | `name not supersetOf "x"` | 
`##supersetOf`          |
+
+A recognized construct replaces the parse errors on its line. 
`drools.lsp.lint.deprecated` sets the severity; `off` leaves the raw parse 
errors in place.
+
+Custom operators are the `drools.evaluator.<id>` entries of 
`META-INF/kie.properties.conf` on the project classpath, read where the engine 
reads them. The server registers those ids with its parser, so `##id` is 
accepted in the editor as it is by the engine; an identifier no configuration 
declares stays a parse error. Operators registered from Java code are not seen.
+
 ## Known Issues
 
 If you find any issues, please report them in [GitHub 
Issues](https://github.com/apache/incubator-kie-tools/issues).
diff --git a/packages/drl-vscode-extension/package.json 
b/packages/drl-vscode-extension/package.json
index 54876667766..a2e976bbd61 100644
--- a/packages/drl-vscode-extension/package.json
+++ b/packages/drl-vscode-extension/package.json
@@ -272,6 +272,18 @@
           "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.deprecated": {
+          "default": "warning",
+          "enum": [
+            "off",
+            "hint",
+            "info",
+            "warning",
+            "error"
+          ],
+          "markdownDescription": "Severity for DRL syntax deprecated for 
removal with the DRL10 parser (apache/incubator-kie#6220): half constraints 
(`age > 18 && < 65`), patterns joined with `&&`/`||` instead of `and`/`or`, 
annotations inside LHS patterns, `agenda-group`, and custom operators without a 
`##` prefix. `off` leaves the raw parse errors in place.",
+          "type": "string"
+        },
         "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 f49346df906..d6f07c0b0ed 100644
--- a/packages/drl-vscode-extension/src/extension.ts
+++ b/packages/drl-vscode-extension/src/extension.ts
@@ -143,6 +143,7 @@ export async function activate(context: 
vscode.ExtensionContext) {
       "drools.lsp.lint.unbalancedParens",
       "drools.lsp.lint.unknownTypes",
       "drools.lsp.lint.mvelPropertyAccess",
+      "drools.lsp.lint.deprecated",
     ];
     for (const prop of lintProps) {
       const value: string | undefined = config.get(prop);
diff --git a/packages/drl-vscode-extension/syntaxes/drl.tmLanguage 
b/packages/drl-vscode-extension/syntaxes/drl.tmLanguage
index 72c75d63b8d..167f10ecbcf 100644
--- a/packages/drl-vscode-extension/syntaxes/drl.tmLanguage
+++ b/packages/drl-vscode-extension/syntaxes/drl.tmLanguage
@@ -12,9 +12,9 @@
        <array>
                <dict>
                        <key>comment</key>
-                       <string>Mathmatical operators</string>
+                       <string>Comparison and logical operators</string>
                        <key>match</key>
-                       <string>[&lt;|&gt;|==|&lt;=|&gt;=|.|!=]</string>
+                       
<string>(==|!=|&lt;=|&gt;=|&lt;|&gt;|&amp;&amp;|\|\|)</string>
                        <key>name</key>
                        <string>keyword.operator.source.drools</string>
                </dict>
@@ -42,6 +42,14 @@
                        <key>name</key>
                        <string>keyword.control.source.drools</string>
                </dict>
+               <dict>
+                       <key>comment</key>
+                       <string>Word operators, built in and custom 
(##supersetOf), colored like the other DRL keywords</string>
+                       <key>match</key>
+                       
<string>##[A-Za-z_][A-Za-z0-9_]*|\b(matches|contains|excludes|memberOf|soundslike|str|after|before|coincides|during|finishes|finishedby|includes|meets|metby|overlaps|overlappedby|starts|startedby)\b</string>
+                       <key>name</key>
+                       <string>keyword.control.source.drools</string>
+               </dict>
                <dict>
                        <key>comment</key>
                        <string>Bound variables like $person, $myList</string>
diff --git 
a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/CustomOperators.java
 
b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/CustomOperators.java
new file mode 100644
index 00000000000..9e9a1cfeb84
--- /dev/null
+++ 
b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/CustomOperators.java
@@ -0,0 +1,140 @@
+/*
+ * 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.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Properties;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+import org.drools.drl.parser.impl.Operator;
+
+/**
+ * The project's custom DRL operators, read from where the engine reads them:
+ * the {@code drools.evaluator.<id>} keys of {@code 
META-INF/kie.properties.conf}
+ * and {@code META-INF/kie.default.properties.conf} on its classpath
+ * ({@code org.kie.internal.utils.ChainedProperties}). The engine instantiates
+ * each configured class, whose static initializer registers the operator; only
+ * the keys are read here, so no project code runs in the server.
+ *
+ * <p>Each id is registered in the parser's {@link Operator} cache, which the
+ * DRL10 grammar consults through {@code isPluggableEvaluator} before accepting
+ * {@code ##id} — so a custom operator parses in the editor as it does in the
+ * engine. Operators registered from Java rather than configured, and ids a
+ * definition registers beyond its property key, are not seen.
+ */
+public final class CustomOperators {
+
+    private static final Logger logger = 
Logger.getLogger(CustomOperators.class.getName());
+
+    static final String PROPERTY_PREFIX = "drools.evaluator.";
+
+    static final String[] CONF_FILES = {
+        "META-INF/kie.properties.conf", 
"META-INF/kie.default.properties.conf"};
+
+    /** Legacy keys the engine's own configuration skips as well. */
+    private static final Set<String> IGNORED_IDS = Set.of("equality", 
"comparable");
+
+    private CustomOperators() {
+    }
+
+    /** Registers every operator id declared on {@code classpathEntries}; 
returns the ids found. */
+    public static Set<String> register(Set<Path> classpathEntries) {
+        Set<String> ids = discover(classpathEntries);
+        for (String id : ids) {
+            Operator.addOperatorToRegistry(id, false);
+            Operator.addOperatorToRegistry(id, true);
+        }
+        return ids;
+    }
+
+    /** Whether {@code id} is an operator the parser accepts after {@code ##}. 
*/
+    public static boolean isRegistered(String id) {
+        return id != null && Operator.determineOperator(id, false) != null;
+    }
+
+    /** The operator ids declared on {@code classpathEntries}, without 
registering them. */
+    static Set<String> discover(Set<Path> classpathEntries) {
+        Set<String> ids = new TreeSet<>();
+        if (classpathEntries == null) {
+            return ids;
+        }
+        for (Path entry : classpathEntries) {
+            try {
+                if (Files.isDirectory(entry)) {
+                    collectFromDirectory(entry, ids);
+                } else if (Files.isRegularFile(entry) && 
entry.toString().endsWith(".jar")) {
+                    collectFromJar(entry, ids);
+                }
+            } catch (IOException | RuntimeException e) {
+                logger.log(Level.FINE, "Skipping operator configuration in " + 
entry, e);
+            }
+        }
+        return ids;
+    }
+
+    private static void collectFromDirectory(Path dir, Set<String> into) 
throws IOException {
+        for (String confFile : CONF_FILES) {
+            Path file = dir.resolve(confFile);
+            if (Files.isRegularFile(file)) {
+                try (InputStream in = Files.newInputStream(file)) {
+                    collectIds(read(in), into);
+                }
+            }
+        }
+    }
+
+    private static void collectFromJar(Path jarPath, Set<String> into) throws 
IOException {
+        try (ZipFile jar = new ZipFile(jarPath.toFile())) {
+            for (String confFile : CONF_FILES) {
+                ZipEntry zipEntry = jar.getEntry(confFile);
+                if (zipEntry != null) {
+                    try (InputStream in = jar.getInputStream(zipEntry)) {
+                        collectIds(read(in), into);
+                    }
+                }
+            }
+        }
+    }
+
+    private static Properties read(InputStream in) throws IOException {
+        Properties properties = new Properties();
+        properties.load(in);
+        return properties;
+    }
+
+    private static void collectIds(Properties properties, Set<String> into) {
+        for (String key : properties.stringPropertyNames()) {
+            if (key.startsWith(PROPERTY_PREFIX)) {
+                String id = key.substring(PROPERTY_PREFIX.length()).trim();
+                if (!id.isEmpty() && !IGNORED_IDS.contains(id)) {
+                    into.add(id);
+                }
+            }
+        }
+    }
+}
diff --git 
a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDiagnosticHelper.java
 
b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDiagnosticHelper.java
index 620145e73f3..7a7ec53a67f 100644
--- 
a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDiagnosticHelper.java
+++ 
b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLDiagnosticHelper.java
@@ -24,6 +24,7 @@ import java.util.Collections;
 import java.util.List;
 
 import org.antlr.v4.runtime.BaseErrorListener;
+import org.antlr.v4.runtime.CommonTokenStream;
 import org.antlr.v4.runtime.Lexer;
 import org.antlr.v4.runtime.RecognitionException;
 import org.antlr.v4.runtime.Recognizer;
@@ -60,8 +61,10 @@ public final class DRLDiagnosticHelper {
 
     /**
      * Parses {@code text} once, returning both the compilation unit and the
-     * syntax diagnostics. Callers that also need the tree (e.g. the 
unknown-type
-     * lint) can reuse it instead of re-parsing.
+     * syntax diagnostics, with the errors that mark a deprecated construct
+     * reported as deprecations instead (see {@link DeprecatedConstructs}).
+     * Callers that also need the tree (e.g. the unknown-type lint) can reuse 
it
+     * instead of re-parsing.
      */
     public static Parsed parse(String text) {
         if (text == null || text.isEmpty()) {
@@ -83,7 +86,12 @@ public final class DRLDiagnosticHelper {
         parser.removeErrorListeners();
         parser.addErrorListener(listener);
 
-        return new Parsed(parser.compilationUnit(), diagnostics);
+        DRL10Parser.CompilationUnitContext compilationUnit = 
parser.compilationUnit();
+        // The lint scans the whole document, including any tail the parser's
+        // error recovery left unread.
+        CommonTokenStream tokens = (CommonTokenStream) parser.getTokenStream();
+        tokens.fill();
+        return new Parsed(compilationUnit, 
DeprecatedConstructs.reclassify(text, tokens.getTokens(), diagnostics));
     }
 
     /**
diff --git 
a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLLintHelper.java
 
b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLLintHelper.java
index c72f0b3dc1c..921820f7547 100644
--- 
a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLLintHelper.java
+++ 
b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DRLLintHelper.java
@@ -1175,9 +1175,10 @@ public final class DRLLintHelper {
     /**
      * Resolves the severity for a pass from its system property: {@code off}
      * returns null (pass disabled), unknown values fall back to
-     * {@code defaultValue}.
+     * {@code defaultValue}. Package-visible so {@link DeprecatedConstructs}
+     * reads its severity the same way.
      */
-    private static DiagnosticSeverity severityFor(String property, String 
defaultValue) {
+    static DiagnosticSeverity severityFor(String property, String 
defaultValue) {
         String value = System.getProperty(property, defaultValue)
                 .trim().toLowerCase();
         switch (value) {
diff --git 
a/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DeprecatedConstructs.java
 
b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DeprecatedConstructs.java
new file mode 100644
index 00000000000..027a7abaa4a
--- /dev/null
+++ 
b/packages/drools-lsp/drools-completion/src/main/java/org/drools/completion/DeprecatedConstructs.java
@@ -0,0 +1,356 @@
+/*
+ * 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.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.antlr.v4.runtime.Token;
+import org.drools.drl.parser.antlr4.DRL10Lexer;
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.DiagnosticSeverity;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+
+/**
+ * Recognizes DRL constructs the engine's legacy parser still compiles but has
+ * deprecated for removal — the families {@code 
org.drools.drl.parser.lang.ParserHelper}
+ * warns about: half constraints, patterns joined with {@code &&}/{@code ||},
+ * annotations after an LHS {@code and}/{@code or}, {@code agenda-group}, and
+ * custom operators without the {@code ##} prefix DRL10 requires
+ * (apache/incubator-kie#6220). A custom operator is recognized only when the
+ * project declares it — see {@link CustomOperators}.
+ *
+ * <p>The engine's own warnings fire once per family per parse and carry no
+ * position, so they cannot drive a migration. Nor can this grammar's parse
+ * errors: it rejects some of these constructs ({@code age > 18 && < 65}),
+ * reads others as something else without an error
+ * ({@code ref.order > $ref.order && < $x}), and accepts some outright
+ * ({@code not( A() && B() )}). Detection therefore scans the token stream,
+ * which the lexer produces even where the parser fails, and relies on what
+ * each parenthesis opened and on section for precision. A recognized 
construct replaces the parse errors on
+ * its line, which describe the same thing less usefully; otherwise the
+ * deprecation is added.
+ *
+ * <p>Severity comes from {@value #PROP_DEPRECATED} (default {@code warning});
+ * {@code off} disables recognition, leaving the raw parse errors in place. At
+ * most one construct is reported per line; a second surfaces once the first is
+ * migrated, which keeps a sweep's output one finding per line rather than one
+ * per recovery attempt.
+ */
+final class DeprecatedConstructs {
+
+    /** System property: severity for deprecated-construct diagnostics. */
+    static final String PROP_DEPRECATED = "drools.lsp.lint.deprecated";
+
+    private static final String SOURCE = "drools-deprecated";
+
+    private static final String REMOVAL = " will be removed in a future 
version (LanguageLevel.DRL10)";
+
+    /**
+     * A recognized construct: the message to show, the last token of its range
+     * and, when the migration is mechanical, the text that should replace that
+     * range. A {@code null} replacement means the construct needs a human
+     * decision (moving or dropping an annotation), so no quick fix is offered.
+     */
+    private static final class Finding {
+        final String message;
+        final String replacement;
+        final Token last;
+
+        Finding(String message, String replacement) {
+            this(message, replacement, null);
+        }
+
+        Finding(String message, String replacement, Token last) {
+            this.message = message;
+            this.replacement = replacement;
+            this.last = last;
+        }
+    }
+
+    /** Tokens that bound the left-hand operand when scanning back from an 
operator. */
+    private static final Set<Integer> OPERAND_BOUNDARY = Set.of(
+            DRL10Lexer.LPAREN, DRL10Lexer.COMMA, DRL10Lexer.AND, 
DRL10Lexer.OR, DRL10Lexer.SEMI);
+
+    /** Relational operators that, with no left operand, form a half 
constraint. */
+    private static final Set<Integer> RELATIONAL = Set.of(
+            DRL10Lexer.LT, DRL10Lexer.GT, DRL10Lexer.LE, DRL10Lexer.GE,
+            DRL10Lexer.EQUAL, DRL10Lexer.NOTEQUAL);
+
+    /** Tokens that can end the left-hand side of a custom-operator 
comparison. */
+    private static final Set<Integer> OPERAND_END = Set.of(
+            DRL10Lexer.IDENTIFIER, DRL10Lexer.RPAREN);
+
+    private static final Set<Integer> LHS_CONNECTIVE = Set.of(
+            DRL10Lexer.DRL_AND, DRL10Lexer.DRL_OR, DRL10Lexer.AND, 
DRL10Lexer.OR);
+
+    /**
+     * Tokens after which a parenthesis at group level groups conditional
+     * elements rather than opening a pattern's constraints or an expression.
+     */
+    private static final Set<Integer> GROUP_OPENER = Set.of(
+            DRL10Lexer.DRL_WHEN, DRL10Lexer.LPAREN,
+            DRL10Lexer.DRL_AND, DRL10Lexer.DRL_OR, DRL10Lexer.AND, 
DRL10Lexer.OR,
+            DRL10Lexer.DRL_NOT, DRL10Lexer.DRL_EXISTS, DRL10Lexer.DRL_FORALL,
+            DRL10Lexer.DRL_ACCUMULATE, DRL10Lexer.DRL_ACC, 
DRL10Lexer.DRL_COLLECT,
+            DRL10Lexer.DRL_GROUPBY);
+
+    private DeprecatedConstructs() {
+    }
+
+    /**
+     * Returns {@code syntaxDiagnostics} with every error that marks a 
deprecated
+     * construct replaced by a diagnostic naming it, and the follow-on errors 
on
+     * those lines removed. {@code lexed} is the full token stream the parse of
+     * {@code text} produced. Returns the input unchanged when recognition is 
off
+     * or nothing matches.
+     */
+    static List<Diagnostic> reclassify(String text, List<Token> lexed, 
List<Diagnostic> syntaxDiagnostics) {
+        List<Diagnostic> syntax = syntaxDiagnostics == null ? List.of() : 
syntaxDiagnostics;
+        if (text == null || text.isEmpty()) {
+            return syntax;
+        }
+        DiagnosticSeverity severity = 
DRLLintHelper.severityFor(PROP_DEPRECATED, "warning");
+        if (severity == null) {
+            return syntax;
+        }
+        List<Token> tokens = defaultChannel(lexed);
+        if (tokens.isEmpty()) {
+            return syntax;
+        }
+
+        List<Diagnostic> found = new ArrayList<>();
+        Set<Integer> claimedLines = new HashSet<>();
+        boolean inLhs = false;
+        // One entry per open parenthesis: true when it groups conditional
+        // elements, false when it opens a pattern's constraints or an 
expression.
+        Deque<Boolean> parens = new ArrayDeque<>();
+        for (int i = 0; i < tokens.size(); i++) {
+            Token token = tokens.get(i);
+            int type = token.getType();
+            if (type == DRL10Lexer.DRL_WHEN) {
+                inLhs = true;
+                parens.clear();
+            } else if (type == DRL10Lexer.DRL_QUERY && !inLhs && 
parens.isEmpty()) {
+                // A query's body is a left-hand side with no `when`; outside
+                // that position `query` is a field name, not a declaration.
+                inLhs = true;
+            } else if (type == DRL10Lexer.DRL_THEN || type == 
DRL10Lexer.DRL_END) {
+                inLhs = false;
+                parens.clear();
+            }
+            boolean atGroupLevel = parens.isEmpty() || parens.peek();
+
+            Finding finding = describe(tokens, i, inLhs, atGroupLevel, text);
+            if (finding != null && claimedLines.add(token.getLine() - 1)) {
+                Diagnostic d = new Diagnostic();
+                d.setSeverity(severity);
+                d.setSource(SOURCE);
+                d.setMessage(finding.message);
+                d.setRange(rangeOf(token, finding.last == null ? token : 
finding.last));
+                // The replacement rides in `data` for the quick fix, the same
+                // way the unknown-type lint carries its suggestion.
+                if (finding.replacement != null) {
+                    d.setData(finding.replacement);
+                }
+                found.add(d);
+            }
+
+            if (type == DRL10Lexer.LPAREN) {
+                Token previous = i > 0 ? tokens.get(i - 1) : null;
+                parens.push(atGroupLevel && previous != null && 
GROUP_OPENER.contains(previous.getType()));
+            } else if (type == DRL10Lexer.RPAREN && !parens.isEmpty()) {
+                parens.pop();
+            }
+        }
+        if (found.isEmpty()) {
+            return syntax;
+        }
+        for (Diagnostic d : syntax) {
+            if (d.getRange() == null || d.getRange().getStart() == null
+                    || 
!claimedLines.contains(d.getRange().getStart().getLine())) {
+                found.add(d);
+            }
+        }
+        return found;
+    }
+
+    /**
+     * Names the deprecated construct at {@code index}, or {@code null} when 
the
+     * tokens there are an ordinary parse failure. Order matters: a {@code &&}
+     * before a relational operator is a half constraint, while the same token
+     * after a closing paren joins two patterns.
+     */
+    private static Finding describe(List<Token> tokens, int index, boolean 
inLhs,
+                                    boolean atGroupLevel, String text) {
+        Token token = tokens.get(index);
+        int type = token.getType();
+        Token next = index + 1 < tokens.size() ? tokens.get(index + 1) : null;
+        Token previous = index > 0 ? tokens.get(index - 1) : null;
+        boolean connective = type == DRL10Lexer.AND || type == DRL10Lexer.OR;
+        boolean insideConstraint = inLhs && !atGroupLevel;
+
+        // Half constraint: `age > 18 && < 65`, inside a pattern's constraints.
+        // The fix repeats the operand the preceding comparison used, so
+        // `ref.order > $ref.order && < $x` becomes
+        // `ref.order > $ref.order && ref.order < $x`.
+        if (insideConstraint && connective && next != null && 
RELATIONAL.contains(next.getType())) {
+            String operand = leftOperandOfPreviousComparison(tokens, index, 
text);
+            return new Finding("Deprecated: half constraint '" + 
token.getText() + " " + next.getText()
+                    + "'" + REMOVAL + " — repeat the left operand.",
+                    operand == null ? null : token.getText() + " " + operand);
+        }
+
+        // Pattern connective: `Person( ... ) && Account( ... )`, also inside a
+        // group such as `not( A() && B() )`. The innermost parenthesis is the
+        // discriminator: the same tokens in `Person( (age > 1) && (age < 5) )`
+        // or `eval( a && b )` sit inside a constraint or an expression.
+        if (inLhs && atGroupLevel && connective
+                && previous != null && previous.getType() == 
DRL10Lexer.RPAREN) {
+            String replacement = type == DRL10Lexer.AND ? "and" : "or";
+            return new Finding("Deprecated: connecting patterns with '" + 
token.getText() + "'"
+                    + REMOVAL + " — use '" + replacement + "' instead.", 
replacement);
+        }
+
+        // Annotation on an LHS connective: `A() or @Annot B()`. Annotations
+        // inside a constraint and after a pattern remain supported. Moving or
+        // dropping it is a judgement call, so no fix is offered.
+        if (inLhs && atGroupLevel && type == DRL10Lexer.AT
+                && previous != null && 
LHS_CONNECTIVE.contains(previous.getType())) {
+            return new Finding("Deprecated: annotation after '" + 
previous.getText() + "' in the LHS"
+                    + REMOVAL + "; annotations elsewhere remain supported.", 
null);
+        }
+
+        // `agenda-group "x"` in a rule's header or at package level — lexed as
+        // the identifier `agenda`, `-`, `group` because DRL10 has no such
+        // keyword. The string value tells it apart from `agenda - group` in a
+        // function body.
+        if (!inLhs && type == DRL10Lexer.IDENTIFIER && 
"agenda".equals(token.getText())
+                && next != null && next.getType() == DRL10Lexer.SUB
+                && index + 3 < tokens.size()
+                && "group".equals(tokens.get(index + 2).getText())
+                && isStringLiteral(tokens.get(index + 3))) {
+            return new Finding("Deprecated: 'agenda-group'" + REMOVAL
+                    + " — use 'ruleflow-group', which the engine treats as the 
same group.",
+                    "ruleflow-group", tokens.get(index + 2));
+        }
+
+        // Custom operator used without its DRL10 prefix: `name supersetOf "x"`
+        // or `name not supersetOf "x"`. Only an operator the project declares
+        // counts (see CustomOperators): an undeclared identifier between two
+        // operands is just as likely a typo, and stays the parse error it is.
+        Token operand = previous != null && previous.getType() == 
DRL10Lexer.DRL_NOT && index > 1
+                ? tokens.get(index - 2) : previous;
+        if (insideConstraint && type == DRL10Lexer.IDENTIFIER
+                && operand != null && next != null
+                && OPERAND_END.contains(operand.getType())
+                && isOperandStart(next)
+                && CustomOperators.isRegistered(token.getText())) {
+            return new Finding("Deprecated: custom operator '" + 
token.getText()
+                    + "' requires a '##' prefix in DRL10 — write '##" + 
token.getText() + "'.",
+                    "##" + token.getText());
+        }
+
+        return null;
+    }
+
+    /**
+     * The source text of the operand on the left of the comparison preceding
+     * {@code connectiveIndex} — the operand a half constraint omitted. Scans
+     * back for that comparison's operator, then for the operand in front of 
it,
+     * stopping at a boundary ({@code (}, {@code ,}, {@code &&}, {@code ||},
+     * {@code ;}) so it never reaches into a neighbouring constraint. Returns
+     * {@code null} when the shape is not recognizable, leaving no quick fix
+     * rather than a wrong one.
+     */
+    private static String leftOperandOfPreviousComparison(List<Token> tokens, 
int connectiveIndex,
+                                                          String text) {
+        int operator = -1;
+        for (int i = connectiveIndex - 1; i >= 0; i--) {
+            int type = tokens.get(i).getType();
+            if (RELATIONAL.contains(type)) {
+                operator = i;
+                break;
+            }
+            if (OPERAND_BOUNDARY.contains(type)) {
+                return null;
+            }
+        }
+        if (operator <= 0) {
+            return null;
+        }
+        int end = operator - 1;
+        int start = end;
+        for (int i = end; i >= 0; i--) {
+            int type = tokens.get(i).getType();
+            if (OPERAND_BOUNDARY.contains(type) || RELATIONAL.contains(type)) {
+                break;
+            }
+            start = i;
+        }
+        Token first = tokens.get(start);
+        Token last = tokens.get(end);
+        int from = first.getStartIndex();
+        int to = last.getStopIndex() + 1;
+        if (from < 0 || to > text.length() || from >= to) {
+            return null;
+        }
+        // Sliced from the source so dotted paths and bindings keep their exact
+        // spelling rather than being rebuilt from token texts.
+        return text.substring(from, to).trim();
+    }
+
+    /** True when a token can begin the right-hand side of a comparison. */
+    private static boolean isOperandStart(Token token) {
+        int type = token.getType();
+        return type == DRL10Lexer.IDENTIFIER
+                || type == DRL10Lexer.DRL_STRING_LITERAL
+                || type == DRL10Lexer.STRING_LITERAL
+                || type == DRL10Lexer.DECIMAL_LITERAL
+                || type == DRL10Lexer.BOOL_LITERAL;
+    }
+
+    private static boolean isStringLiteral(Token token) {
+        int type = token.getType();
+        return type == DRL10Lexer.DRL_STRING_LITERAL || type == 
DRL10Lexer.STRING_LITERAL;
+    }
+
+    /** Tokens on the default channel; comments and whitespace are irrelevant 
here. */
+    private static List<Token> defaultChannel(List<Token> lexed) {
+        List<Token> out = new ArrayList<>();
+        for (Token token : lexed) {
+            if (token.getChannel() == Token.DEFAULT_CHANNEL && token.getType() 
!= Token.EOF) {
+                out.add(token);
+            }
+        }
+        return out;
+    }
+
+    private static Range rangeOf(Token first, Token last) {
+        int length = last.getText() == null ? 1 : Math.max(1, 
last.getText().length());
+        return new Range(new Position(first.getLine() - 1, 
first.getCharPositionInLine()),
+                new Position(last.getLine() - 1, last.getCharPositionInLine() 
+ length));
+    }
+}
diff --git 
a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/CustomOperatorsTest.java
 
b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/CustomOperatorsTest.java
new file mode 100644
index 00000000000..eedaee75d81
--- /dev/null
+++ 
b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/CustomOperatorsTest.java
@@ -0,0 +1,104 @@
+/*
+ * 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.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The operator cache is JVM-wide and never emptied, so ids registered by one
+ * test stay registered; tests asserting absence use ids nothing here declares.
+ */
+class CustomOperatorsTest {
+
+    static Path classDirDeclaring(Path root, String confFile, String... ids) 
throws Exception {
+        Path conf = root.resolve(confFile);
+        Files.createDirectories(conf.getParent());
+        StringBuilder text = new StringBuilder();
+        for (String id : ids) {
+            
text.append("drools.evaluator.").append(id).append("=com.example.").append(id).append("Definition\n");
+        }
+        Files.writeString(conf, text.toString());
+        return root;
+    }
+
+    @Test
+    void discoversIdsFromAClassDirectoryAndAJar(@TempDir Path dir) throws 
Exception {
+        Path classes = classDirDeclaring(dir.resolve("classes"), 
"META-INF/kie.properties.conf",
+                "supersetOf", "equality", "comparable");
+        Path jar = dir.resolve("dep.jar");
+        try (JarOutputStream out = new 
JarOutputStream(Files.newOutputStream(jar))) {
+            out.putNextEntry(new 
JarEntry("META-INF/kie.default.properties.conf"));
+            out.write("drools.evaluator.soundsSimilar = 
com.example.SoundsSimilarDefinition\n"
+                    .getBytes());
+            out.closeEntry();
+        }
+
+        assertThat(CustomOperators.discover(Set.of(classes, jar)))
+                .containsExactly("soundsSimilar", "supersetOf");
+    }
+
+    @Test
+    void discoversIdsFromBothConfigurationFilesInOneJar(@TempDir Path dir) 
throws Exception {
+        Path jar = dir.resolve("dep.jar");
+        try (JarOutputStream out = new 
JarOutputStream(Files.newOutputStream(jar))) {
+            out.putNextEntry(new JarEntry("META-INF/kie.properties.conf"));
+            
out.write("drools.evaluator.closeTo=com.example.CloseToDefinition\n".getBytes());
+            out.closeEntry();
+            out.putNextEntry(new 
JarEntry("META-INF/kie.default.properties.conf"));
+            
out.write("drools.evaluator.farFrom=com.example.FarFromDefinition\n".getBytes());
+            out.closeEntry();
+        }
+
+        
assertThat(CustomOperators.discover(Set.of(jar))).containsExactly("closeTo", 
"farFrom");
+    }
+
+    @Test
+    void entriesWithoutAConfigurationYieldNothing(@TempDir Path dir) throws 
Exception {
+        Path plainDir = Files.createDirectories(dir.resolve("plain"));
+        Path notAJar = Files.writeString(dir.resolve("notes.txt"), 
"drools.evaluator.x=y\n");
+
+        assertThat(CustomOperators.discover(Set.of(plainDir, notAJar, 
dir.resolve("missing"))))
+                .isEmpty();
+        assertThat(CustomOperators.discover(null)).isEmpty();
+    }
+
+    /** After registration the DRL10 parser accepts {@code ##id}, as the 
engine does. */
+    @Test
+    void registeredIdsParseWithThePrefix(@TempDir Path dir) throws Exception {
+        Path classes = classDirDeclaring(dir, "META-INF/kie.properties.conf", 
"supersetOf");
+
+        
assertThat(CustomOperators.register(Set.of(classes))).containsExactly("supersetOf");
+
+        assertThat(CustomOperators.isRegistered("supersetOf")).isTrue();
+        
assertThat(CustomOperators.isRegistered("neverDeclaredAnywhere")).isFalse();
+        assertThat(DRLDiagnosticHelper.parse(
+                "package p;\nrule R when Person( name ##supersetOf \"x\" ) 
then end\n").diagnostics)
+                .isEmpty();
+    }
+}
diff --git 
a/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DeprecatedConstructsTest.java
 
b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DeprecatedConstructsTest.java
new file mode 100644
index 00000000000..56fc71508ad
--- /dev/null
+++ 
b/packages/drools-lsp/drools-completion/src/test/java/org/drools/completion/DeprecatedConstructsTest.java
@@ -0,0 +1,406 @@
+/*
+ * 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.nio.file.Path;
+import java.util.List;
+import java.util.Set;
+
+import org.eclipse.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.DiagnosticSeverity;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Deprecated constructs the engine's legacy parser still compiles. Most
+ * fixtures are parse failures for the DRL10 grammar — which is why the raw
+ * message needs replacing — so they also pin that recognition survives 
whatever
+ * the parser's recovery does around them. Custom-operator cases declare their
+ * operator the way a project does, through {@code 
META-INF/kie.properties.conf}
+ * on a classpath entry; the operator cache is JVM-wide, so the id stays
+ * registered for the rest of the run.
+ */
+class DeprecatedConstructsTest {
+
+    private static final String HEAD =
+            "package p;\ndeclare Person\n  name : String\n  age : int\nend\n";
+
+    private static void declareCustomOperator(Path dir, String id) throws 
Exception {
+        CustomOperators.register(Set.of(
+                CustomOperatorsTest.classDirDeclaring(dir, 
"META-INF/kie.properties.conf", id)));
+    }
+
+    @BeforeEach
+    @AfterEach
+    void reset() {
+        System.clearProperty(DeprecatedConstructs.PROP_DEPRECATED);
+    }
+
+    private static List<Diagnostic> diagnose(String drl) {
+        return DRLDiagnosticHelper.parse(drl).diagnostics;
+    }
+
+    private static Diagnostic deprecation(String drl) {
+        List<Diagnostic> found = diagnose(drl).stream()
+                .filter(d -> "drools-deprecated".equals(d.getSource()))
+                .toList();
+        assertThat(found).as("expected exactly one deprecation diagnostic 
in:\n" + drl).hasSize(1);
+        return found.get(0);
+    }
+
+    @Test
+    void namesAHalfConstraint() {
+        Diagnostic d = deprecation(HEAD + "rule R when Person( age > 18 && < 
65 ) then end\n");
+        assertThat(d.getMessage()).contains("half constraint").contains("&& <")
+                .contains("repeat the left operand");
+        assertThat(d.getSeverity()).isEqualTo(DiagnosticSeverity.Warning);
+    }
+
+    @Test
+    void namesAHalfConstraintAgainstABinding() {
+        Diagnostic d = deprecation(HEAD
+                + "rule R when $p : Person( age > 1 ) Person( age > 18 && < 
$p.age ) then end\n");
+        assertThat(d.getMessage()).contains("half constraint");
+    }
+
+    @Test
+    void namesPatternsConnectedWithAnd() {
+        Diagnostic d = deprecation(HEAD
+                + "rule R when Person( age > 1 ) && Person( name != null ) 
then end\n");
+        assertThat(d.getMessage()).contains("connecting patterns with 
'&&'").contains("use 'and'");
+    }
+
+    @Test
+    void namesPatternsConnectedWithOr() {
+        Diagnostic d = deprecation(HEAD
+                + "rule R when Person( age > 1 ) || Person( name != null ) 
then end\n");
+        assertThat(d.getMessage()).contains("connecting patterns with 
'||'").contains("use 'or'");
+    }
+
+    @Test
+    void namesPatternsConnectedInsideAGroup() {
+        assertThat(deprecation(HEAD
+                + "rule R when ( Person( age > 1 ) && Person( name != null ) ) 
then end\n")
+                .getData()).isEqualTo("and");
+        assertThat(deprecation(HEAD
+                + "rule R when not( Person( age > 1 ) && Person( age < 1 ) ) 
then end\n")
+                .getData()).isEqualTo("and");
+        assertThat(deprecation(HEAD
+                + "rule R when exists( Person( age > 1 ) || Person( age < 1 ) 
) then end\n")
+                .getData()).isEqualTo("or");
+    }
+
+    @Test
+    void doesNotFlagConnectivesInsideAnEval() {
+        assertThat(diagnose(HEAD + "rule R when eval( 1 > 0 && 2 > 1 ) then 
end\n"))
+                .allSatisfy(d -> 
assertThat(d.getSource()).isNotEqualTo("drools-deprecated"));
+    }
+
+    /** The legacy parser warns on an annotation that follows an LHS {@code 
and}/{@code or}. */
+    @Test
+    void namesAnAnnotationOnAnLhsConnective() {
+        assertThat(deprecation(HEAD
+                + "rule R when Person( age > 1 ) or @A Person( age < 1 ) then 
end\n").getMessage())
+                .contains("annotation after 'or'");
+        assertThat(deprecation(HEAD
+                + "rule R when ( Person( age > 1 ) and @A Person( age < 1 ) ) 
then end\n").getMessage())
+                .contains("annotation after 'and'");
+        assertThat(deprecation(HEAD
+                + "rule R when ( or @A Person( age > 1 ) Person( age < 1 ) ) 
then end\n").getMessage())
+                .contains("annotation after 'or'");
+    }
+
+    /**
+     * DRL10 keeps annotations after {@code &&}/{@code ||} inside a 
constraint, and
+     * neither parser accepts one at the start of a constraint.
+     */
+    @Test
+    void doesNotFlagAnnotationsInsideAPattern() {
+        for (String fixture : new String[] {
+            "rule R when Person( age > 1 && @A(1) age < 5 ) then end\n",
+            "rule R when Person( @watch(age) age > 1 ) then end\n",
+        }) {
+            assertThat(diagnose(HEAD + fixture)).as(fixture)
+                    .allSatisfy(d -> 
assertThat(d.getSource()).isNotEqualTo("drools-deprecated"));
+        }
+    }
+
+    @Test
+    void namesAgendaGroup() {
+        Diagnostic d = deprecation(HEAD
+                + "rule R\n  agenda-group \"g\"\n  when Person( age > 1 ) then 
end\n");
+        
assertThat(d.getMessage()).contains("'agenda-group'").contains("ruleflow-group");
+    }
+
+    /** DRL10 accepts attributes as top-level statements, applying to every 
rule in the package. */
+    @Test
+    void namesAPackageLevelAgendaGroup() {
+        Diagnostic d = deprecation(HEAD
+                + "agenda-group \"g\"\nrule R when Person( age > 1 ) then 
end\n");
+        assertThat(d.getMessage()).contains("'agenda-group'");
+    }
+
+    /** A function body is lexed as code, where {@code agenda - group} is a 
subtraction. */
+    @Test
+    void doesNotFlagASubtractionInAFunctionBodyAsAgendaGroup() {
+        assertThat(diagnose(HEAD
+                + "function int f(int agenda, int group) { return agenda - 
group; }\n"))
+                .allSatisfy(d -> 
assertThat(d.getSource()).isNotEqualTo("drools-deprecated"));
+    }
+
+    /** A query's body is a left-hand side without a {@code when}. */
+    @Test
+    void namesConstructsInsideAQuery(@TempDir Path dir) throws Exception {
+        declareCustomOperator(dir, "queryOp");
+
+        assertThat(deprecation(HEAD + "query q() Person( age > 18 && < 65 ) 
end\n").getMessage())
+                .contains("half constraint");
+        assertThat(deprecation(HEAD + "query q() Person( age > 1 ) && Person( 
name != null ) end\n")
+                .getMessage()).contains("connecting patterns with '&&'");
+        assertThat(deprecation(HEAD + "query q() Person( age > 1 ) or @A 
Person( age < 1 ) end\n")
+                .getMessage()).contains("annotation after 'or'");
+        assertThat(deprecation(HEAD + "query q() Person( name queryOp \"x\" ) 
end\n").getMessage())
+                .contains("custom operator 'queryOp'");
+    }
+
+    @Test
+    void doesNotFlagAQuerysParameters() {
+        assertThat(diagnose(HEAD
+                + "query q(String name, int age) Person( this.name == name, 
this.age == age ) end\n"))
+                .allSatisfy(d -> 
assertThat(d.getSource()).isNotEqualTo("drools-deprecated"));
+    }
+
+    /** {@code query} used as a field name must not restart the section 
mid-pattern. */
+    @Test
+    void aQueryKeywordInsideAPatternDoesNotResetTheSection() {
+        assertThat(deprecation(HEAD + "rule R when Person( query > 1 && < 5 ) 
then end\n").getMessage())
+                .contains("half constraint");
+    }
+
+    @Test
+    void doesNotFlagAgendaGroupInCommentsStringsOrPatterns() {
+        String[] fixtures = {
+            "/* agenda-group \"g\" */\nrule R when Person( age > 1 ) then 
end\n",
+            "// agenda-group \"g\"\nrule R when Person( age > 1 ) then end\n",
+            "rule R when Person( name == \"agenda-group \\\"g\\\"\" ) then 
end\n",
+            "rule R when Person( agenda-group \"g\" ) then end\n",
+            "query q() Person( agenda-group \"g\" ) end\n",
+        };
+        for (String fixture : fixtures) {
+            assertThat(diagnose(HEAD + fixture)).as(fixture)
+                    .allSatisfy(d -> 
assertThat(d.getSource()).isNotEqualTo("drools-deprecated"));
+        }
+    }
+
+    @Test
+    void reportsOneConstructPerLine() {
+        assertThat(deprecation(HEAD + "rule R when Person( age > 1 && < 5 && < 
3 ) then end\n")
+                .getRange().getStart().getCharacter()).isEqualTo("rule R when 
Person( age > 1 ".length());
+    }
+
+    @Test
+    void reportsConstructsOnSeparateLinesSeparately() {
+        assertThat(diagnose(HEAD + "rule R when\n"
+                + "  Person( age > 1 && < 5 )\n"
+                + "  Person( age > 2 && < 6 )\n"
+                + "then end\n"))
+                .filteredOn(d -> "drools-deprecated".equals(d.getSource()))
+                .hasSize(2);
+    }
+
+    @Test
+    void namesACustomOperatorMissingItsPrefix(@TempDir Path dir) throws 
Exception {
+        declareCustomOperator(dir, "customOp");
+
+        Diagnostic d = deprecation(HEAD + "rule R when Person( name customOp 
\"x\" ) then end\n");
+        assertThat(d.getMessage()).contains("custom operator 
'customOp'").contains("##customOp");
+    }
+
+    /** An identifier no configuration declares is a typo until proven 
otherwise. */
+    @Test
+    void doesNotFlagAnUndeclaredIdentifierAsACustomOperator() {
+        assertThat(diagnose(HEAD + "rule R when Person( name undeclaredOp 
\"x\" ) then end\n"))
+                .isNotEmpty()
+                .allSatisfy(d -> 
assertThat(d.getSource()).isNotEqualTo("drools-deprecated"));
+    }
+
+    /** The construct's line loses its follow-on parse errors; other lines 
keep theirs. */
+    @Test
+    void replacesTheParseErrorsOnTheConstructsLine() {
+        List<Diagnostic> all = diagnose(HEAD + "rule R when Person( age > 18 
&& < 65 ) then end\n");
+        assertThat(all).allSatisfy(d ->
+                assertThat(d.getSource()).isEqualTo("drools-deprecated"));
+    }
+
+    @Test
+    void severityIsConfigurableAndOffLeavesTheParseErrors() {
+        String drl = HEAD + "rule R when Person( age > 18 && < 65 ) then 
end\n";
+
+        System.setProperty(DeprecatedConstructs.PROP_DEPRECATED, "error");
+        
assertThat(deprecation(drl).getSeverity()).isEqualTo(DiagnosticSeverity.Error);
+
+        System.setProperty(DeprecatedConstructs.PROP_DEPRECATED, "off");
+        assertThat(diagnose(drl))
+                .isNotEmpty()
+                .allSatisfy(d -> 
assertThat(d.getSource()).isNotEqualTo("drools-deprecated"));
+    }
+
+    /** A mistyped constraint is not a deprecation: no operand precedes the 
literal. */
+    @Test
+    void doesNotMisreportAMistypedConstraintAsACustomOperator() {
+        assertThat(diagnose(HEAD + "rule R when Person( age 18 ) then end\n"))
+                .as("a missing operator should stay a syntax error")
+                .allSatisfy(d -> 
assertThat(d.getSource()).isNotEqualTo("drools-deprecated"));
+    }
+
+    /** Real rules span lines; the error need not land on the construct's 
line. */
+    @Test
+    void namesAHalfConstraintInAMultiLineRule() {
+        Diagnostic d = deprecation(HEAD + """
+                rule R
+                  when
+                    Person(
+                      age > 18
+                      && < 65,
+                      name != null
+                    )
+                  then
+                end
+                """);
+        assertThat(d.getMessage()).contains("half constraint");
+    }
+
+    /**
+     * A half constraint whose operands are bound paths parses cleanly under 
the
+     * DRL10 grammar, so there is no parse error to replace — detection must 
not
+     * depend on one, or the construct stays invisible in exactly the files 
that
+     * contain it.
+     */
+    @Test
+    void namesAHalfConstraintInADocumentThatParsesCleanly() {
+        String drl = """
+                package p;
+                rule R
+                  when
+                    $ref : Fact( order > 0 )
+                    Fact( ref.order > $ref.order && < $presentOrder )
+                  then
+                end
+                """;
+        assertThat(DRLDiagnosticHelper.parse(drl).diagnostics)
+                .as("the grammar accepts this, so any parse error would 
invalidate the premise")
+                .allSatisfy(d -> 
assertThat(d.getSource()).isEqualTo("drools-deprecated"));
+        assertThat(deprecation(drl).getMessage()).contains("half constraint");
+    }
+
+    /** Parenthesised constraints are not pattern connectives. */
+    @Test
+    void doesNotFlagParenthesisedConstraintsAsPatternConnectives() {
+        assertThat(diagnose(HEAD
+                + "rule R when Person( (age > 1) && (age < 5) ) then end\n"))
+                .as("&& inside a constraint list is ordinary DRL")
+                .isEmpty();
+    }
+
+    /** Rule-level annotations sit outside the when section and are not 
deprecated. */
+    @Test
+    void doesNotFlagRuleLevelAnnotations() {
+        assertThat(diagnose(HEAD
+                + "rule R\n  @implements(\"X-1\")\n  when Person( age > 1 ) 
then end\n"))
+                .isEmpty();
+    }
+
+    /**
+     * The half-constraint fix repeats the operand the preceding comparison 
used,
+     * so applying it to the diagnostic's range yields valid DRL10.
+     */
+    @Test
+    void halfConstraintFixRepeatsTheLeftOperand() {
+        Diagnostic d = deprecation("""
+                package p;
+                rule R
+                  when
+                    $fact : Fact( order > 0 )
+                    Fact( order > $fact.order && < $presentOrder )
+                  then
+                end
+                """);
+        assertThat(d.getData()).isEqualTo("&& order");
+    }
+
+    @Test
+    void patternConnectiveFixIsTheKeyword() {
+        assertThat(deprecation(HEAD
+                + "rule R when Person( age > 1 ) && Person( name != null ) 
then end\n")
+                .getData()).isEqualTo("and");
+        assertThat(deprecation(HEAD
+                + "rule R when Person( age > 1 ) || Person( name != null ) 
then end\n")
+                .getData()).isEqualTo("or");
+    }
+
+    @Test
+    void customOperatorFixAddsThePrefix(@TempDir Path dir) throws Exception {
+        declareCustomOperator(dir, "customOp");
+
+        assertThat(deprecation(HEAD + "rule R when Person( name customOp \"x\" 
) then end\n")
+                .getData()).isEqualTo("##customOp");
+    }
+
+    @Test
+    void negatedCustomOperatorFixAddsThePrefix(@TempDir Path dir) throws 
Exception {
+        declareCustomOperator(dir, "negatedOp");
+
+        assertThat(deprecation(HEAD + "rule R when Person( name not negatedOp 
\"x\" ) then end\n")
+                .getData()).isEqualTo("##negatedOp");
+        assertThat(diagnose(HEAD + "rule R when Person( name not ##negatedOp 
\"x\" ) then end\n"))
+                .isEmpty();
+    }
+
+    /** Since 6.x the engine aliases ruleflow-group to the agenda group of the 
same name. */
+    @Test
+    void agendaGroupFixRenamesTheAttribute() {
+        Diagnostic d = deprecation(HEAD + "rule R\n  agenda-group \"g\"\n  
when Person( age > 1 ) then end\n");
+        assertThat(d.getData()).isEqualTo("ruleflow-group");
+        assertThat(d.getRange().getStart().getCharacter()).isEqualTo(2);
+        assertThat(d.getRange().getEnd().getCharacter()).isEqualTo(2 + 
"agenda-group".length());
+    }
+
+    @Test
+    void noFixIsOfferedForAnnotations() {
+        assertThat(deprecation(HEAD + "rule R when Person( age > 1 ) or @A 
Person( age < 1 ) then end\n")
+                .getData()).isNull();
+    }
+
+    /** No fix rather than a wrong one when the preceding comparison is 
unreadable. */
+    @Test
+    void halfConstraintWithoutAPrecedingComparisonOffersNoFix() {
+        assertThat(deprecation(HEAD + "rule R when Person( && < 65 ) then 
end\n").getData())
+                .isNull();
+    }
+
+    @Test
+    void cleanDrlProducesNothing() {
+        assertThat(diagnose(HEAD + "rule R when Person( age > 18 ) then 
end\n")).isEmpty();
+    }
+}
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 7e322c5ae78..20e0fa6f9ed 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
@@ -676,6 +676,7 @@ public class DroolsLspDocumentService implements 
TextDocumentService {
             }
             List<Either<Command, CodeAction>> actions =
                     new ArrayList<>(buildUnknownTypeActions(uri, inRange));
+            actions.addAll(buildDeprecatedConstructActions(uri, inRange));
             if (mvelOn) {
                 actions.addAll(buildPropertyAccessActions(uri, text, 
requested));
             }
@@ -692,9 +693,25 @@ public class DroolsLspDocumentService implements 
TextDocumentService {
      */
     static List<Either<Command, CodeAction>> buildUnknownTypeActions(
             String uri, List<Diagnostic> diagnostics) {
+        return replacementActions(uri, diagnostics, "drools-type");
+    }
+
+    /**
+     * Builds the migration quick-fixes for deprecated-construct
+     * ({@code drools-deprecated}) diagnostics, whose replacement rides in
+     * {@code data} the same way. A construct whose migration is not mechanical
+     * — an annotation that must move or go — carries none and so offers no 
fix.
+     */
+    static List<Either<Command, CodeAction>> buildDeprecatedConstructActions(
+            String uri, List<Diagnostic> diagnostics) {
+        return replacementActions(uri, diagnostics, "drools-deprecated");
+    }
+
+    private static List<Either<Command, CodeAction>> replacementActions(
+            String uri, List<Diagnostic> diagnostics, String source) {
         List<Either<Command, CodeAction>> actions = new ArrayList<>();
         for (Diagnostic d : diagnostics) {
-            if (!"drools-type".equals(d.getSource())) {
+            if (!source.equals(d.getSource())) {
                 continue;
             }
             String suggestion = suggestionOf(d.getData());
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 4aacd8f1233..21605fa6669 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
@@ -31,6 +31,7 @@ import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.TreeSet;
 import java.util.concurrent.CompletableFuture;
 import java.util.logging.Level;
 import java.util.logging.Logger;
@@ -38,6 +39,7 @@ import java.util.logging.Logger;
 import org.drools.completion.ClassIndex;
 import org.drools.completion.ClassMemberIndex;
 import org.drools.completion.ClasspathTypeMembers;
+import org.drools.completion.CustomOperators;
 import org.drools.completion.DRLDeclaredTypeParser;
 import org.drools.completion.JavaSourceRoots;
 import org.drools.completion.JavaSourceTypeIndex;
@@ -87,6 +89,8 @@ public class DroolsLspServer implements LanguageServer, 
LanguageClientAware {
     private volatile Set<Path> classpathEntries = Set.of();
     private volatile Set<Path> buildOutputDirs = Set.of();
     private volatile List<Path> mavenRoots = List.of();
+    private volatile Set<String> jarCustomOperators = Set.of();
+    private volatile Set<String> registeredCustomOperators = Set.of();
     private volatile ClassIndex jarClassIndex = ClassIndex.empty();
     private volatile ClassMemberIndex classMemberIndex = 
ClassMemberIndex.empty();
     private volatile JavaSourceTypeIndex javaSourceIndex = 
JavaSourceTypeIndex.empty();
@@ -221,6 +225,7 @@ public class DroolsLspServer implements LanguageServer, 
LanguageClientAware {
      * waiting for the next edit/save.
      */
     private void publishClassIndex() {
+        registerCustomOperators();
         ClassIndex outputIndex = ClassIndex.build(buildOutputDirs);
         ClassIndex merged = ClassIndex.merge(ClassIndex.merge(jarClassIndex, 
outputIndex),
                 ClassIndex.of(javaSourceIndex.classNames()));
@@ -246,12 +251,33 @@ public class DroolsLspServer implements LanguageServer, 
LanguageClientAware {
         return dependencyClasspathResolved;
     }
 
+    /**
+     * Registers the custom operators declared on the project's own build 
output
+     * and on its dependencies, whenever either set may have changed. The
+     * dependency list from {@code mvn dependency:build-classpath} does not 
carry
+     * the project's own {@code target/classes}, which is where its
+     * {@code META-INF/kie.properties.conf} lives, so both sets are scanned.
+     * Dependency jars change only when the classpath resolves, so their ids 
are
+     * read there and only the class directories are rescanned here.
+     */
+    private void registerCustomOperators() {
+        Set<Path> scanned = filterDirectories(classpathEntries);
+        scanned.addAll(buildOutputDirs);
+        Set<String> operators = new TreeSet<>(jarCustomOperators);
+        operators.addAll(CustomOperators.register(scanned));
+        if (!operators.equals(registeredCustomOperators)) {
+            registeredCustomOperators = operators;
+            logger.info(() -> "Custom DRL operators: " + operators);
+        }
+    }
+
     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);
+        this.jarCustomOperators = CustomOperators.register(jars);
         // Member lookup reflects over the full classpath (jars + class dirs)
         // lazily — building the index itself loads no classes.
         swapMemberIndex(ClassMemberIndex.of(entries));
@@ -286,13 +312,13 @@ public class DroolsLspServer implements LanguageServer, 
LanguageClientAware {
     }
 
     /**
-     * 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.
+     * Test-only entry point: runs the same fast-path 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, locate the build output
+     * directories, publish the merged class index) without the async plumbing
+     * or a real LSP client — so a test exercising that path doesn't need to
+     * duplicate {@code initialize}'s sys-prop parsing.
      */
     void initializeJavaSourceTypingForTest(Path workspaceRoot) {
         this.workspaceRootPath = workspaceRoot;
@@ -303,6 +329,7 @@ public class DroolsLspServer implements LanguageServer, 
LanguageClientAware {
                 System.getProperty("drools.lsp.java.packageFilters"));
         refreshJavaSourceIndex(workspaceRoot);
         swapMemberIndex(ClassMemberIndex.of(classpathEntries));
+        buildOutputDirs = 
MavenClasspathResolver.resolveBuildOutputDirs(workspaceRoot);
         publishClassIndex();
     }
 
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 723f729ba53..d8fa6d1d1f0 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
@@ -679,4 +679,45 @@ class DroolsLspDocumentServiceTest {
 
         
assertThat(server.getTextDocumentService().formatterOptions().lineLength()).isEqualTo(80);
     }
+
+    // ── deprecated constructs ────────────────────────────────────────────
+
+    @Test
+    void deprecatedConstructsReachTheDocumentDiagnostics() {
+        DroolsLspDocumentService service = getDroolsLspDocumentService(
+                "package p;\nrule R when Person( age > 18 && < 65 ) then 
end\n");
+
+        assertThat(service.validate("myDocument"))
+                .isNotEmpty()
+                .allSatisfy(d -> 
assertThat(d.getSource()).isEqualTo("drools-deprecated"));
+    }
+
+    @Test
+    void buildDeprecatedConstructActionsOffersTheMigrationEdit() {
+        Diagnostic d = new Diagnostic();
+        d.setSource("drools-deprecated");
+        d.setData("&& person.name");
+        d.setRange(new Range(new Position(4, 30), new Position(4, 32)));
+
+        List<Either<Command, CodeAction>> actions =
+                
DroolsLspDocumentService.buildDeprecatedConstructActions("myDocument", 
List.of(d));
+
+        assertThat(actions).hasSize(1);
+        CodeAction ca = actions.get(0).getRight();
+        assertThat(ca.getTitle()).isEqualTo("Replace with '&& person.name'");
+        assertThat(ca.getEdit().getChanges().get("myDocument"))
+                .singleElement()
+                .satisfies(e -> assertThat(e.getNewText()).isEqualTo("&& 
person.name"));
+    }
+
+    /** Constructs with no mechanical migration carry no data, so offer no 
fix. */
+    @Test
+    void buildDeprecatedConstructActionsSkipsDiagnosticsWithoutAReplacement() {
+        Diagnostic d = new Diagnostic();
+        d.setSource("drools-deprecated");
+        d.setRange(new Range(new Position(2, 4), new Position(2, 5)));
+
+        
assertThat(DroolsLspDocumentService.buildDeprecatedConstructActions("myDocument",
 List.of(d)))
+                .isEmpty();
+    }
 }
\ No newline at end of file
diff --git 
a/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/DroolsLspServerTest.java
 
b/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/DroolsLspServerTest.java
index 6522f8c3981..ca34af7bbaa 100644
--- 
a/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/DroolsLspServerTest.java
+++ 
b/packages/drools-lsp/drools-lsp-server/src/test/java/org/drools/lsp/server/DroolsLspServerTest.java
@@ -27,11 +27,14 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
 
 import com.google.gson.JsonNull;
 import com.google.gson.JsonObject;
 import com.google.gson.JsonParser;
 import org.drools.completion.ClassIndex;
+import org.drools.completion.CustomOperators;
 import org.drools.formatter.FormatterOptions;
 import org.eclipse.lsp4j.ClientCapabilities;
 import org.eclipse.lsp4j.ConfigurationItem;
@@ -78,6 +81,60 @@ class DroolsLspServerTest {
         assertThat(index.getMatching("Foo")).contains("com.example.Foo");
     }
 
+    @Test
+    void rebuildClassIndexRegistersTheProjectsCustomOperators() throws 
IOException {
+        DroolsLspServer server = 
TestHelperMethods.getDroolsLspServerForDocument("");
+        Path conf = tempDir.resolve("META-INF/kie.properties.conf");
+        Files.createDirectories(conf.getParent());
+        Files.writeString(conf, 
"drools.evaluator.nearlyEquals=com.example.NearlyEqualsDefinition\n");
+        server.setClasspathEntriesForTest(Set.of(tempDir));
+
+        server.rebuildClassIndex();
+
+        assertThat(CustomOperators.isRegistered("nearlyEquals")).isTrue();
+    }
+
+    @Test
+    void dependencyJarsAreScannedForOperatorsOnlyWhenTheClasspathResolves() 
throws IOException {
+        DroolsLspServer server = 
TestHelperMethods.getDroolsLspServerForDocument("");
+        Path jar = tempDir.resolve("dep.jar");
+        writeOperatorJar(jar, "jarDeclaredBefore");
+        server.setClasspathEntriesForTest(Set.of(jar));
+        server.rebuildClassIndex();
+        writeOperatorJar(jar, "jarDeclaredAfter");
+
+        server.rebuildClassIndex();
+
+        assertThat(CustomOperators.isRegistered("jarDeclaredBefore")).isTrue();
+        assertThat(CustomOperators.isRegistered("jarDeclaredAfter")).isFalse();
+    }
+
+    private static void writeOperatorJar(Path jar, String id) throws 
IOException {
+        try (JarOutputStream out = new 
JarOutputStream(Files.newOutputStream(jar))) {
+            out.putNextEntry(new JarEntry("META-INF/kie.properties.conf"));
+            out.write(("drools.evaluator." + id + 
"=com.example.Definition\n").getBytes());
+            out.closeEntry();
+        }
+    }
+
+    /**
+     * The project's own declaration lives in its build output, which the
+     * dependency classpath never lists, and it must be known from the first
+     * publish at startup rather than from a later rebuild.
+     */
+    @Test
+    void startupPublishRegistersOperatorsDeclaredInTheProjectsBuildOutput() 
throws IOException {
+        DroolsLspServer server = 
TestHelperMethods.getDroolsLspServerForDocument("");
+        Files.writeString(tempDir.resolve("pom.xml"), "<project/>");
+        Path conf = 
tempDir.resolve("target/classes/META-INF/kie.properties.conf");
+        Files.createDirectories(conf.getParent());
+        Files.writeString(conf, 
"drools.evaluator.roughlyEquals=com.example.RoughlyEqualsDefinition\n");
+
+        server.initializeJavaSourceTypingForTest(tempDir);
+
+        assertThat(CustomOperators.isRegistered("roughlyEquals")).isTrue();
+    }
+
     @Test
     void didChangeWatchedFilesTriggersRebuild() throws Exception {
         DroolsLspServer server = 
TestHelperMethods.getDroolsLspServerForDocument("");


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to