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

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/master by this push:
     new 8309173c5a GROOVY-12021: Add DO macro for monadic comprehensions over 
Optional/Stream/Awaitable and @Monadic types
8309173c5a is described below

commit 8309173c5a9fe92efaff4084315d39cfc9d35291
Author: Paul King <[email protected]>
AuthorDate: Tue May 19 15:30:37 2026 +1000

    GROOVY-12021: Add DO macro for monadic comprehensions over 
Optional/Stream/Awaitable and @Monadic types
---
 src/main/java/groovy/transform/Monadic.java        |  52 ++++
 .../org/apache/groovy/runtime/Comprehensions.java  | 190 ++++++++++++++
 .../groovy/runtime/MonadicCarrierRegistry.java     | 180 +++++++++++++
 src/spec/doc/core-async-await.adoc                 |   2 +
 src/spec/doc/core-parallel-collections.adoc        |   4 +
 subprojects/groovy-macro-library/build.gradle      |   1 +
 .../groovy/macrolib/MacroLibGroovyMethods.java     | 115 +++++++++
 .../src/spec/doc/_monadic-comprehensions.adoc      | 175 +++++++++++++
 .../spec/test/MonadicComprehensionsSpecTest.groovy | 144 +++++++++++
 .../{build.gradle => src/test/groovy/fj/F.groovy}  |  24 +-
 .../src/test/groovy/fj/data/Option.groovy          |  51 ++++
 .../org/apache/groovy/macrolib/DoMacroTest.groovy  | 217 ++++++++++++++++
 .../org/apache/groovy/macrolib/DoStaticTest.groovy | 275 ++++++++++++++++++++
 .../macrolib/FunctionalJavaCarrierTest.groovy      |  69 +++++
 .../macrolib/MonadicComprehensionsTest.groovy      | 191 ++++++++++++++
 .../groovy/typecheckers/MonadicChecker.groovy      | 282 +++++++++++++++++++++
 .../groovy/typecheckers/MonadicShapeChecker.groovy | 279 ++++++++++++++++++++
 .../typecheckers/MonadicShapeCheckerTest.groovy    | 265 +++++++++++++++++++
 18 files changed, 2501 insertions(+), 15 deletions(-)

diff --git a/src/main/java/groovy/transform/Monadic.java 
b/src/main/java/groovy/transform/Monadic.java
new file mode 100644
index 0000000000..c9f5736304
--- /dev/null
+++ b/src/main/java/groovy/transform/Monadic.java
@@ -0,0 +1,52 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package groovy.transform;
+
+import org.apache.groovy.lang.annotation.Incubating;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a type as participating in monadic comprehensions (the {@code DO} 
macro).
+ * Optional members declare the bind/map method names when they diverge from 
the
+ * structural convention ({@code flatMap}/{@code map}). When both are omitted, 
the
+ * annotation merely opts the type in and the structural defaults apply.
+ * <p>
+ * Modelled on {@link groovy.transform.Reducer}: a pure marker, read by 
tooling,
+ * with no AST transformation. The runtime dispatcher and the type checker 
match
+ * this annotation <em>by simple name</em> ({@code Monadic}), exactly as
+ * {@code groovy.typecheckers.CombinerChecker} matches {@code @Reducer}/{@code 
@Associative}.
+ *
+ * @since 6.0.0
+ */
+@Documented
+@Incubating
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Monadic {
+    /** The flatMap-shaped bind method name. Empty means the structural 
default {@code flatMap}. */
+    String bind() default "";
+
+    /** The map method name. Empty means the structural default {@code map}. */
+    String map() default "";
+}
diff --git a/src/main/java/org/apache/groovy/runtime/Comprehensions.java 
b/src/main/java/org/apache/groovy/runtime/Comprehensions.java
new file mode 100644
index 0000000000..78b85efc73
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/Comprehensions.java
@@ -0,0 +1,190 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.runtime;
+
+import groovy.lang.Closure;
+import org.apache.groovy.lang.annotation.Incubating;
+import org.codehaus.groovy.runtime.DefaultGroovyMethods;
+import org.codehaus.groovy.runtime.InvokerHelper;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.util.function.Function;
+
+/**
+ * Runtime bind/map dispatcher for monadic comprehensions &mdash; the emit 
target
+ * of the {@code DO} macro.
+ * <p>
+ * The macro runs at {@code SEMANTIC_ANALYSIS}, <em>before</em> type checking, 
so it
+ * cannot know the carrier's bind-method name and cannot emit it directly. 
Instead it
+ * emits {@code Comprehensions.bind(carrier) { x -> ... }} calls; this class 
resolves
+ * the carrier-specific method at runtime (dynamic Groovy), while the
+ * {@code groovy.typecheckers.MonadicChecker} type-checking extension 
specialises this
+ * one signature under {@code @CompileStatic}.
+ * <p>
+ * This is runtime support invoked from generated bytecode, hence its 
placement in
+ * core alongside the rest of the Groovy runtime; the {@code DO} macro and the 
type
+ * checker are compile-time only and remain in their optional modules.
+ * <p>
+ * Participation is resolved first-match-wins:
+ * <ol>
+ *   <li>standard allow-list ({@link MonadicCarrierRegistry});</li>
+ *   <li>structural ({@code flatMap}/{@code map} present);</li>
+ *   <li>{@code @Monadic} opt-in (matched by simple name; honours {@code 
bind}/{@code map} overrides).</li>
+ * </ol>
+ * A configured marker interface is a further opt-in mechanism that is not yet
+ * implemented.
+ * <p>
+ * Surface generosity: the carrier's bind method may accept a {@link Function} 
or a
+ * {@code Closure}; the closure is adapted to whichever the target declares. 
Monad
+ * laws are not enforced &mdash; structural participation, algebraic-law 
obligation
+ * on the implementer (the {@code @Reducer}/{@code @Associative} treatment).
+ *
+ * @since 6.0.0
+ */
+@Incubating
+public final class Comprehensions {
+
+    private Comprehensions() {}
+
+    /** Bind (flatMap-shaped): {@code carrier.<bind>(x -> fn(x))} where {@code 
fn} yields the same carrier. */
+    public static Object bind(Object carrier, Closure<?> fn) {
+        return dispatch(carrier, fn, true);
+    }
+
+    /** Map: {@code carrier.<map>(x -> fn(x))} where {@code fn} yields a plain 
value. */
+    public static Object map(Object carrier, Closure<?> fn) {
+        return dispatch(carrier, fn, false);
+    }
+
+    private static Object dispatch(Object carrier, Closure<?> fn, boolean 
bindRole) {
+        if (carrier == null) {
+            throw new IllegalArgumentException(
+                "Monadic comprehension carrier is null; null cannot 
participate as a carrier");
+        }
+        Class<?> type = carrier.getClass();
+        String method = resolveMethodName(carrier, type, bindRole);
+        if (method == null) {
+            String role = bindRole ? "bind (flatMap-shaped)" : "map";
+            String structural = bindRole ? "flatMap" : "map";
+            throw new IllegalArgumentException(
+                "Type " + type.getName() + " does not participate in monadic 
comprehensions: "
+              + "no " + role + " method found (not in the standard carrier 
allow-list, "
+              + "has no structural '" + structural + "' method, and is not 
annotated @Monadic)");
+        }
+        Object arg = adaptClosure(type, method, fn);
+        return InvokerHelper.invokeMethod(carrier, method, arg);
+    }
+
+    private static String resolveMethodName(Object carrier, Class<?> type, 
boolean bindRole) {
+        // 1. standard allow-list (Class- or name-keyed)
+        String[] bindMap = MonadicCarrierRegistry.lookupBindMap(carrier);
+        if (bindMap != null) {
+            return bindRole ? bindMap[0] : bindMap[1];
+        }
+        // 2. structural
+        String structural = bindRole ? "flatMap" : "map";
+        if (findSingleArgMethod(type, structural) != null) {
+            return structural;
+        }
+        // 3. @Monadic opt-in (matched by simple name, like 
@Reducer/@Associative)
+        String monadic = monadicMethodName(type, bindRole);
+        if (monadic != null && findSingleArgMethod(type, monadic) != null) {
+            return monadic;
+        }
+        return null;
+    }
+
+    private static String monadicMethodName(Class<?> type, boolean bindRole) {
+        for (Class<?> c = type; c != null && c != Object.class; c = 
c.getSuperclass()) {
+            String n = readMonadicMember(c.getDeclaredAnnotations(), bindRole);
+            if (n != null) return n;
+            for (Class<?> i : c.getInterfaces()) {
+                String ni = readMonadicMember(i.getDeclaredAnnotations(), 
bindRole);
+                if (ni != null) return ni;
+            }
+        }
+        return null;
+    }
+
+    private static String readMonadicMember(Annotation[] annotations, boolean 
bindRole) {
+        for (Annotation a : annotations) {
+            if (!"Monadic".equals(a.annotationType().getSimpleName())) 
continue;
+            String configured = invokeStringMember(a, bindRole ? "bind" : 
"map");
+            if (configured == null || configured.isEmpty()) {
+                return bindRole ? "flatMap" : "map"; // opted in, structural 
defaults
+            }
+            return configured;
+        }
+        return null;
+    }
+
+    private static String invokeStringMember(Annotation a, String member) {
+        try {
+            Object v = a.annotationType().getMethod(member).invoke(a);
+            return v == null ? null : v.toString();
+        } catch (ReflectiveOperationException ignored) {
+            return null;
+        }
+    }
+
+    private static Method findSingleArgMethod(Class<?> type, String name) {
+        for (Method m : type.getMethods()) {
+            if (m.getParameterCount() == 1 && m.getName().equals(name)
+                    && !m.isBridge() && !m.isSynthetic()) {
+                return m;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Adapt the generator closure to whatever single-arg type the carrier's 
bind
+     * method declares:
+     * <ul>
+     *   <li>a {@code Closure}-typed parameter receives the closure 
directly;</li>
+     *   <li>a {@link Function}-typed parameter receives a thin wrapper;</li>
+     *   <li>any other functional interface (for example Functional Java's
+     *       {@code fj.F}) receives the closure coerced to that interface.</li>
+     * </ul>
+     */
+    private static Object adaptClosure(Class<?> type, String method, final 
Closure<?> fn) {
+        Method m = findSingleArgMethod(type, method);
+        Class<?> pt = (m != null) ? m.getParameterTypes()[0] : null;
+        if (pt == null || Closure.class.isAssignableFrom(pt)) {
+            return fn;
+        }
+        if (pt == Function.class) {
+            // Exact Function (not a supertype): Object is a supertype too, 
and a
+            // structural Groovy carrier declaring 'flatMap(c)' lands with 
pt=Object;
+            // the user's body typically calls c.call(x), which would fail 
against
+            // a Function wrapper. Untyped Object falls through to the asType
+            // branch below, which returns the Closure unchanged.
+            return new Function<Object, Object>() {
+                @Override
+                public Object apply(Object value) {
+                    return fn.call(value);
+                }
+            };
+        }
+        // General SAM coercion: proxy the closure as the declared interface.
+        // For pt == Object this returns the Closure unchanged.
+        return DefaultGroovyMethods.asType(fn, pt);
+    }
+}
diff --git 
a/src/main/java/org/apache/groovy/runtime/MonadicCarrierRegistry.java 
b/src/main/java/org/apache/groovy/runtime/MonadicCarrierRegistry.java
new file mode 100644
index 0000000000..877aa27d31
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/MonadicCarrierRegistry.java
@@ -0,0 +1,180 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.runtime;
+
+import groovy.concurrent.Awaitable;
+import org.apache.groovy.lang.annotation.Incubating;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.CompletionStage;
+import java.util.stream.Stream;
+
+/**
+ * The standard carrier allow-list for monadic comprehensions: stdlib and
+ * Groovy-core carriers whose bind/map method names diverge from the structural
+ * {@code flatMap}/{@code map} convention.
+ * <p>
+ * Carriers known by {@code Class} (always on the classpath) are matched by
+ * {@code isInstance}; carriers known only by name &mdash; third-party 
libraries
+ * that Groovy must not depend on, such as Functional Java and Vavr &mdash; are
+ * matched by walking the value's type hierarchy and comparing fully-qualified
+ * names. The {@code CompletionStage} entry covers {@code CompletableFuture};
+ * the {@code Awaitable} entry covers {@code DataflowVariable}; the Functional
+ * Java entries use that library's {@code bind}/{@code map} convention; the
+ * Vavr entries use the structural {@code flatMap}/{@code map} convention.
+ *
+ * @since 6.0.0
+ */
+@Incubating
+public final class MonadicCarrierRegistry {
+
+    /** One {@code Class}-keyed allow-list row. */
+    public static final class Entry {
+        private final Class<?> carrier;
+        private final String bind;
+        private final String map;
+
+        Entry(Class<?> carrier, String bind, String map) {
+            this.carrier = carrier;
+            this.bind = bind;
+            this.map = map;
+        }
+
+        public Class<?> carrier() { return carrier; }
+        public String bind() { return bind; }
+        public String map() { return map; }
+
+        @Override
+        public String toString() {
+            return carrier.getName() + " { bind=" + bind + ", map=" + map + " 
}";
+        }
+    }
+
+    /** One name-keyed allow-list row, for carriers Groovy must not depend on. 
*/
+    public static final class NamedEntry {
+        private final String carrierName;
+        private final String bind;
+        private final String map;
+
+        NamedEntry(String carrierName, String bind, String map) {
+            this.carrierName = carrierName;
+            this.bind = bind;
+            this.map = map;
+        }
+
+        public String carrierName() { return carrierName; }
+        public String bind() { return bind; }
+        public String map() { return map; }
+
+        @Override
+        public String toString() {
+            return carrierName + " { bind=" + bind + ", map=" + map + " }";
+        }
+    }
+
+    private static final List<Entry> ENTRIES;
+    private static final List<NamedEntry> NAMED_ENTRIES;
+    static {
+        List<Entry> e = new ArrayList<Entry>();
+        e.add(new Entry(Optional.class,        "flatMap",     "map"));
+        e.add(new Entry(Stream.class,          "flatMap",     "map"));
+        e.add(new Entry(CompletionStage.class, "thenCompose", "thenApply")); 
// covers CompletableFuture
+        e.add(new Entry(Awaitable.class,       "thenCompose", "then"));      
// covers DataflowVariable
+        ENTRIES = Collections.unmodifiableList(e);
+
+        // Functional Java (org.functionaljava) — recognised by name; no 
dependency.
+        List<NamedEntry> n = new ArrayList<NamedEntry>();
+        n.add(new NamedEntry("fj.data.Option",     "bind", "map"));
+        n.add(new NamedEntry("fj.data.List",       "bind", "map"));
+        n.add(new NamedEntry("fj.data.Stream",     "bind", "map"));
+        n.add(new NamedEntry("fj.data.Validation", "bind", "map"));
+        n.add(new NamedEntry("fj.P1",              "bind", "map"));
+
+        // Vavr (io.vavr) — recognised by name; no dependency. Vavr's control
+        // carriers use the structural flatMap/map convention, so they are
+        // covered by the default dispatcher even without an entry here; the
+        // entries are retained so the carrier names appear explicitly in the
+        // standard allow-list and pass the MonadicChecker's participation test
+        // without requiring a structural match.
+        n.add(new NamedEntry("io.vavr.control.Option",     "flatMap", "map"));
+        n.add(new NamedEntry("io.vavr.control.Try",        "flatMap", "map"));
+        n.add(new NamedEntry("io.vavr.control.Either",     "flatMap", "map"));
+        n.add(new NamedEntry("io.vavr.control.Validation", "flatMap", "map"));
+
+        NAMED_ENTRIES = Collections.unmodifiableList(n);
+    }
+
+    private MonadicCarrierRegistry() {}
+
+    /** The {@code Class}-keyed allow-list, exposed for the type-checking 
extension. */
+    public static List<Entry> entries() {
+        return ENTRIES;
+    }
+
+    /** The name-keyed allow-list, exposed for the type-checking extension. */
+    public static List<NamedEntry> namedEntries() {
+        return NAMED_ENTRIES;
+    }
+
+    /**
+     * The {@code [bind, map]} method names for the given carrier value, or
+     * {@code null} if it is not on either allow-list. {@code Class} entries 
are
+     * tried first ({@code isInstance}), then name entries against the value's
+     * full type hierarchy (so {@code fj.data.Some} matches {@code 
fj.data.Option}).
+     */
+    public static String[] lookupBindMap(Object carrier) {
+        if (carrier == null) return null;
+        for (Entry entry : ENTRIES) {
+            if (entry.carrier().isInstance(carrier)) {
+                return new String[]{entry.bind(), entry.map()};
+            }
+        }
+        if (!NAMED_ENTRIES.isEmpty()) {
+            for (Class<?> t : supertypes(carrier.getClass())) {
+                String name = t.getName();
+                for (NamedEntry entry : NAMED_ENTRIES) {
+                    if (entry.carrierName().equals(name)) {
+                        return new String[]{entry.bind(), entry.map()};
+                    }
+                }
+            }
+        }
+        return null;
+    }
+
+    private static Set<Class<?>> supertypes(Class<?> start) {
+        Set<Class<?>> seen = new LinkedHashSet<Class<?>>();
+        Deque<Class<?>> queue = new ArrayDeque<Class<?>>();
+        queue.add(start);
+        while (!queue.isEmpty()) {
+            Class<?> c = queue.poll();
+            if (c == null || !seen.add(c)) continue;
+            if (c.getSuperclass() != null) queue.add(c.getSuperclass());
+            Collections.addAll(queue, c.getInterfaces());
+        }
+        return seen;
+    }
+}
diff --git a/src/spec/doc/core-async-await.adoc 
b/src/spec/doc/core-async-await.adoc
index 9816b59960..858a52bcda 100644
--- a/src/spec/doc/core-async-await.adoc
+++ b/src/spec/doc/core-async-await.adoc
@@ -534,3 +534,5 @@ type, e.g. `AtomicInteger` for a shared counter, or 
thread-safe types from
 
 All keywords (`async`, `await`, `defer`) are contextual — they can still be
 used as variable or method names in existing code.
+
+include::../../../subprojects/groovy-macro-library/src/spec/doc/_monadic-comprehensions.adoc[leveloffset=+1]
diff --git a/src/spec/doc/core-parallel-collections.adoc 
b/src/spec/doc/core-parallel-collections.adoc
index f007e7ac9f..a00754379d 100644
--- a/src/spec/doc/core-parallel-collections.adoc
+++ b/src/spec/doc/core-parallel-collections.adoc
@@ -302,6 +302,10 @@ because blocking one doesn't consume an OS thread.
 |Mixed
 |`async`/`await` with `Pool.io()`
 |Virtual threads handle both compute and I/O
+
+|Value composition over a monadic carrier (`Optional`, `Awaitable`, `Stream`, 
...)
+|`DO` comprehensions (`groovy-macro-library`)
+|Flattens nested `flatMap`/`thenCompose` chains into uniform `name in source` 
notation
 |===
 
 To illustrate, consider processing 100 items where each involves
diff --git a/subprojects/groovy-macro-library/build.gradle 
b/subprojects/groovy-macro-library/build.gradle
index f334e87fc9..397fc80a8c 100644
--- a/subprojects/groovy-macro-library/build.gradle
+++ b/subprojects/groovy-macro-library/build.gradle
@@ -24,6 +24,7 @@ dependencies {
     implementation rootProject
     implementation projects.groovyMacro
     testImplementation projects.groovyTest
+    testImplementation projects.groovyTypecheckers // MonadicChecker 
@CompileStatic tests
 }
 
 groovyLibrary {
diff --git 
a/subprojects/groovy-macro-library/src/main/groovy/org/apache/groovy/macrolib/MacroLibGroovyMethods.java
 
b/subprojects/groovy-macro-library/src/main/groovy/org/apache/groovy/macrolib/MacroLibGroovyMethods.java
index f2e39c11ef..5a7b08a061 100644
--- 
a/subprojects/groovy-macro-library/src/main/groovy/org/apache/groovy/macrolib/MacroLibGroovyMethods.java
+++ 
b/subprojects/groovy-macro-library/src/main/groovy/org/apache/groovy/macrolib/MacroLibGroovyMethods.java
@@ -20,24 +20,39 @@ package org.apache.groovy.macrolib;
 
 import groovy.lang.GString;
 import groovy.lang.NamedValue;
+import org.apache.groovy.runtime.Comprehensions;
 import org.codehaus.groovy.ast.ClassHelper;
 import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.VariableScope;
+import org.codehaus.groovy.ast.expr.BinaryExpression;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
 import org.codehaus.groovy.ast.expr.ConstantExpression;
 import org.codehaus.groovy.ast.expr.Expression;
 import org.codehaus.groovy.ast.expr.GStringExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.Statement;
 import org.codehaus.groovy.macro.runtime.Macro;
 import org.codehaus.groovy.macro.runtime.MacroContext;
+import org.codehaus.groovy.syntax.SyntaxException;
+import org.codehaus.groovy.syntax.Types;
 
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 
 import static org.codehaus.groovy.ast.tools.GeneralUtils.args;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.block;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.callX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.classX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.closureX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.constX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.ctorX;
 import static org.codehaus.groovy.ast.tools.GeneralUtils.listX;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.param;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.stmt;
 
 /**
  * Macro library helpers for string and named-value expansion.
@@ -48,6 +63,7 @@ public final class MacroLibGroovyMethods {
     private MacroLibGroovyMethods() {}
 
     private static final ClassNode NAMED_VALUE = 
ClassHelper.make(NamedValue.class);
+    private static final ClassNode COMPREHENSIONS = 
ClassHelper.make(Comprehensions.class);
 
     /**
      * Builds a GString expression that labels each supplied expression with 
its source text.
@@ -182,4 +198,103 @@ public final class MacroLibGroovyMethods {
         throw new IllegalStateException("MacroLibGroovyMethods.NVL(Object...) 
should never be called at runtime. Are you sure you are using it correctly?");
     }
 
+    /**
+     * Monadic comprehension macro ({@code DO}). Rewrites a comma-separated 
list of
+     * {@code name in expression} generators followed by a body closure into a 
nested
+     * chain of {@link Comprehensions#bind} calls &mdash; the do-notation 
desugaring:
+     * <pre>
+     *   DO(x in m1, y in f(x)) { body }
+     *   ==&gt;
+     *   Comprehensions.bind(m1) { x -&gt; Comprehensions.bind(f(x)) { y -&gt; 
body } }
+     * </pre>
+     * Every generator becomes a bind; the body is the innermost closure body 
and
+     * must itself yield a carrier value (the do-notation rule &mdash; no 
implicit
+     * lifting). Carrier-specific bind dispatch is deferred to runtime
+     * ({@link Comprehensions}) because macros expand before type checking.
+     *
+     * @param ctx the current macro context
+     * @param exps the generators (each {@code name in expression}) followed 
by the body closure
+     * @return the nested bind-chain expression
+     */
+    @Macro
+    public static Expression DO(MacroContext ctx, final Expression... exps) {
+        if (exps == null || exps.length < 2) {
+            return error(ctx, ctx.getCall(),
+                "DO requires at least one 'name in expression' generator and a 
trailing closure body");
+        }
+        Expression last = exps[exps.length - 1];
+        if (!(last instanceof ClosureExpression)) {
+            return error(ctx, last, "DO requires a trailing closure body, e.g. 
DO(x in m1) { ... }");
+        }
+        ClosureExpression body = (ClosureExpression) last;
+        if (body.getParameters() != null && body.getParameters().length > 0) {
+            return error(ctx, body,
+                "DO body closure must not declare parameters; generator names 
are already in scope");
+        }
+
+        int genCount = exps.length - 1;
+        List<String> names = new ArrayList<String>(genCount);
+        List<Expression> sources = new ArrayList<Expression>(genCount);
+        for (int i = 0; i < genCount; i++) {
+            Expression g = exps[i];
+            if (!(g instanceof BinaryExpression)
+                    || ((BinaryExpression) g).getOperation().getType() != 
Types.KEYWORD_IN) {
+                return error(ctx, g, "DO generator must have the form 'name in 
expression'");
+            }
+            BinaryExpression bin = (BinaryExpression) g;
+            if (!(bin.getLeftExpression() instanceof VariableExpression)) {
+                return error(ctx, bin.getLeftExpression(),
+                    "DO generator binding must be a simple name, e.g. x in 
m1");
+            }
+            names.add(((VariableExpression) 
bin.getLeftExpression()).getName());
+            sources.add(bin.getRightExpression());
+        }
+
+        // Build innermost-outward: the last generator's closure carries the 
body.
+        // Copy source positions from the originating user nodes onto every
+        // synthetic AST node we create — fresh AST nodes default to line/col 
-1,
+        // and several downstream code paths (notably
+        // {@link 
org.codehaus.groovy.transform.stc.StaticTypeCheckingVisitor#addStaticTypeError})
+        // silently drop diagnostics on positionless nodes. Anchoring 
everything
+        // back to the user's {@code name in expr} clause keeps both error
+        // attribution and IDE navigation pointing at real source.
+        Expression chain = null;
+        for (int i = genCount - 1; i >= 0; i--) {
+            Expression g = exps[i];
+            Expression nameExp = ((BinaryExpression) g).getLeftExpression();
+            Statement closureBody = (i == genCount - 1) ? body.getCode() : 
block(stmt(chain));
+            Parameter p = param(ClassHelper.dynamicType(), names.get(i));
+            p.setSourcePosition(nameExp);
+            ClosureExpression lambda = closureX(new Parameter[]{p}, 
closureBody);
+            lambda.setVariableScope(new VariableScope());
+            // innermost lambda mirrors the user's body closure; outer lambdas 
the generator
+            lambda.setSourcePosition(i == genCount - 1 ? body : g);
+            Expression receiver = classX(COMPREHENSIONS);
+            receiver.setSourcePosition(g);
+            Expression argList = args(sources.get(i), lambda);
+            argList.setSourcePosition(g);
+            chain = callX(receiver, "bind", argList);
+            chain.setSourcePosition(g);
+        }
+        return chain;
+    }
+
+    /**
+     * Runtime stub for {@link #DO(MacroContext, Expression...)}.
+     *
+     * @param self the receiver
+     * @param args the runtime values
+     * @return never returns normally
+     */
+    public static Object DO(Object self, Object... args) {
+        throw new IllegalStateException("MacroLibGroovyMethods.DO(Object...) 
should never be called at runtime. Are you sure you are using it correctly?");
+    }
+
+    private static Expression error(MacroContext ctx, Expression node, String 
message) {
+        ctx.getSourceUnit().addError(new SyntaxException(message + '\n', 
node));
+        // Return a non-macro expression: the error fails compilation, and 
returning
+        // the original DO(...) call would have it re-expanded ad infinitum.
+        return constX(null);
+    }
+
 }
diff --git 
a/subprojects/groovy-macro-library/src/spec/doc/_monadic-comprehensions.adoc 
b/subprojects/groovy-macro-library/src/spec/doc/_monadic-comprehensions.adoc
new file mode 100644
index 0000000000..b3d92dba9d
--- /dev/null
+++ b/subprojects/groovy-macro-library/src/spec/doc/_monadic-comprehensions.adoc
@@ -0,0 +1,175 @@
+//////////////////////////////////////////
+
+  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.
+
+//////////////////////////////////////////
+
+[[monadic-comprehensions]]
+= Monadic comprehensions (Incubating)
+
+The `groovy-macro-library` module provides `DO`, a comprehension macro that
+rewrites a sequence of `name in source` generators followed by a body into a
+chain of bind operations on the *carrier* type. It gives Scala-style
+for-comprehension / Haskell-style do-notation ergonomics to any type with
+monadic shape — `Optional`, `Stream`, `CompletableFuture`, Groovy's
+`Awaitable` and `DataflowVariable`, and user-defined types that opt in.
+
+NOTE: `DO` is incubating (since Groovy 6.0) and may change. The macro is
+compile-time only; the generated code calls the `Comprehensions` runtime
+support in core, so a program using `DO` needs only the core `groovy` jar at
+runtime — `groovy-macro-library` is a compile-time dependency.
+
+[source,groovy]
+----
+include::../test/MonadicComprehensionsSpecTest.groovy[tags=do_basic,indent=0]
+----
+
+== Desugaring
+
+Every generator becomes a bind; the body is the innermost closure body and
+must itself yield a value of the carrier type (the do-notation rule — there is
+no implicit lifting in this version). The example above expands to:
+
+[source,groovy]
+----
+Comprehensions.bind(Optional.of(2)) { a ->
+    Comprehensions.bind(Optional.of(3)) { b ->
+        Optional.of(a + b)
+    }
+}
+----
+
+Because the macro expands before type information is available, it does not
+emit a carrier-specific method name directly. `Comprehensions.bind` resolves
+the right operation at runtime; under `@CompileStatic` the
+`groovy.typecheckers.MonadicChecker` extension supplies the static types (see
+<<monadic-static>>).
+
+IMPORTANT: For all but the most trivial uses, `DO` under `@CompileStatic` or
+`@TypeChecked` requires the `MonadicChecker` extension
+(`@CompileStatic(extensions = 'groovy.typecheckers.MonadicChecker')`). The
+runtime dispatcher signature `Comprehensions.bind(Object, Closure):Object`
+erases the carrier's element type and the comprehension's result type, so
+without the extension each generator's bound name and the whole `DO`
+expression both fall through to `Object` &mdash; which the static type
+checker will reject as soon as the body or downstream code does anything
+type-specific with either. See <<monadic-static>>.
+
+A name bound by an earlier generator is in scope in the source expression of
+every later generator and in the body:
+
+[source,groovy]
+----
+include::../test/MonadicComprehensionsSpecTest.groovy[tags=do_dependent,indent=0]
+----
+
+Short-circuiting is delivered by the carrier, not by the macro: an empty or
+failed carrier simply propagates and the body is never evaluated.
+
+[source,groovy]
+----
+include::../test/MonadicComprehensionsSpecTest.groovy[tags=do_shortcircuit,indent=0]
+----
+
+== Participating carriers
+
+A type participates as a carrier when, in order, the first match winning:
+
+. it is on the standard allow-list — `java.util.Optional`,
+  `java.util.stream.Stream`, `java.util.concurrent.CompletionStage` (covering
+  `CompletableFuture`), and `groovy.concurrent.Awaitable` (covering
+  `DataflowVariable`);
+. it is _structural_ — it has a single-argument `flatMap` (and, for the map
+  role, `map`);
+. it is annotated `@groovy.transform.Monadic`, optionally declaring
+  non-conventional method names.
+
+The allow-list also recognises common Functional Java carriers by name &mdash;
+`fj.data.Option`, `fj.data.List`, `fj.data.Stream`, `fj.data.Validation` and
+`fj.P1` &mdash; using that library's `bind`/`map` convention. Groovy takes no
+dependency on Functional Java; the names are matched reflectively, and the
+generator closure is coerced to `fj.F` automatically. `fj.data.Either` is not
+directly monadic in Functional Java (bind lives on its `.right()`/`.left()`
+projections) and is not a carrier; use a projection explicitly.
+
+NOTE: `Awaitable` and `DataflowVariable` bind via `thenCompose`; their `then`
+method is the _map_ operation, not bind. `DO` over `Awaitable` therefore
+composes asynchronous values without an imperative `await` at each step:
+
+[source,groovy]
+----
+include::../test/MonadicComprehensionsSpecTest.groovy[tags=do_awaitable,indent=0]
+----
+
+`Stream` yields the usual cartesian composition:
+
+[source,groovy]
+----
+include::../test/MonadicComprehensionsSpecTest.groovy[tags=do_stream,indent=0]
+----
+
+A user type opts in with `@Monadic`, which may name a non-conventional bind
+and map method:
+
+[source,groovy]
+----
+include::../test/MonadicComprehensionsSpecTest.groovy[tags=do_monadic,indent=0]
+----
+
+The monad laws (left identity, right identity, associativity) are not enforced
+by the compiler; as with `@Reducer`/`@Associative`, lawful behaviour is the
+participating type's responsibility.
+
+[[monadic-static]]
+== Static type checking
+
+Activate the `MonadicChecker` type-checking extension to use `DO` under
+`@CompileStatic` or `@TypeChecked`. It types each generator's bound name as
+the carrier's element type (so the body type-checks), restores the
+comprehension's result type, and rejects a non-participating carrier with a
+compile error naming the type and the missing shape:
+
+[source,groovy]
+----
+include::../test/MonadicComprehensionsSpecTest.groovy[tags=do_static,indent=0]
+----
+
+TIP: The typical symptom of forgetting the extension is a static type
+checking error reporting that some operation cannot be found on `Object`
+&mdash; either inside the body (a generator's bound name has erased to
+`Object`) or on the `DO` expression itself (the result has erased to
+`Object`). Adding `extensions = 'groovy.typecheckers.MonadicChecker'` to
+the `@CompileStatic`/`@TypeChecked` annotation resolves it.
+
+[[monadic-when]]
+== When to use `DO`
+
+`DO` is a value-composition notation. It complements, rather than competes
+with, the concurrency constructs: use imperative `async`/`await` when code
+reads as a sequence of dependent steps you intend to run now (see
+<<async-prefer-values>> and <<async-choose-right-tool>>); reach for `DO`
+when the composed value is the deliverable — an `Awaitable` to combine
+further, an `Optional`/validation result, a parser result, or a custom
+`Result` type — and you want one uniform notation across carriers.
+
+CAUTION: This version is deliberately narrow. The body must yield a carrier
+value (no implicit `pure`/`unit` lifting); there are no `if` guard clauses;
+and each `DO` works over a single carrier (nest `DO` blocks for more).
+`break`/`continue` are not valid in the body, and `return` follows the
+standard closure rule. These are the same closure-body constraints the
+`@Parallel` for-loop transform documents.
diff --git 
a/subprojects/groovy-macro-library/src/spec/test/MonadicComprehensionsSpecTest.groovy
 
b/subprojects/groovy-macro-library/src/spec/test/MonadicComprehensionsSpecTest.groovy
new file mode 100644
index 0000000000..08530207d3
--- /dev/null
+++ 
b/subprojects/groovy-macro-library/src/spec/test/MonadicComprehensionsSpecTest.groovy
@@ -0,0 +1,144 @@
+/*
+ *  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.
+ */
+
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+
+/**
+ * Worked, inline-tested examples for the "Monadic comprehensions" 
specification
+ * chapter (_monadic-comprehensions.adoc). Each tagged region is included into 
the
+ * manual and run as part of the build.
+ */
+final class MonadicComprehensionsSpecTest {
+
+    @Test
+    void basic() {
+        assertScript '''
+        // tag::do_basic[]
+        def result = DO(a in Optional.of(2),
+                        b in Optional.of(3)) {
+            Optional.of(a + b)
+        }
+        assert result.get() == 5
+        // end::do_basic[]
+        '''
+    }
+
+    @Test
+    void shortCircuit() {
+        assertScript '''
+        // tag::do_shortcircuit[]
+        def result = DO(a in Optional.empty(),
+                        b in Optional.of(3)) {
+            Optional.of(b)            // never reached
+        }
+        assert result.isEmpty()
+        // end::do_shortcircuit[]
+        '''
+    }
+
+    @Test
+    void dependentGenerators() {
+        assertScript '''
+        // tag::do_dependent[]
+        def result = DO(a in Optional.of(10),
+                        b in Optional.of(a * 2)) {   // b's source depends on a
+            Optional.of(a + b)
+        }
+        assert result.get() == 30
+        // end::do_dependent[]
+        '''
+    }
+
+    @Test
+    void stream() {
+        assertScript '''
+        // tag::do_stream[]
+        import java.util.stream.Stream
+
+        def pairs = DO(x in Stream.of(1, 2),
+                       y in Stream.of('a', 'b')) {
+            Stream.of("$x$y".toString())
+        }
+        assert pairs.toList() == ['1a', '1b', '2a', '2b']
+        // end::do_stream[]
+        '''
+    }
+
+    @Test
+    void awaitable() {
+        assertScript '''
+        // tag::do_awaitable[]
+        import groovy.concurrent.Awaitable
+        import static org.apache.groovy.runtime.async.AsyncSupport.await
+
+        def total = DO(a in Awaitable.of(2),
+                       b in Awaitable.of(40)) {
+            Awaitable.of(a + b)
+        }
+        assert await(total) == 42
+        // end::do_awaitable[]
+        '''
+    }
+
+    @Test
+    void monadicAnnotation() {
+        assertScript '''
+        // tag::do_monadic[]
+        import groovy.transform.Monadic
+        import java.util.function.Function
+
+        @Monadic(bind = 'chain', map = 'transform')
+        class Result {
+            final Object value
+            Result(Object value) { this.value = value }
+            Result chain(Function f) { (Result) f.apply(value) }
+            Result transform(Function f) { new Result(f.apply(value)) }
+        }
+
+        def r = DO(a in new Result(3),
+                   b in new Result(4)) {
+            new Result(a * b)
+        }
+        assert r.value == 12
+        // end::do_monadic[]
+        '''
+    }
+
+    @Test
+    void compileStatic() {
+        assertScript '''
+        // tag::do_static[]
+        import groovy.transform.CompileStatic
+
+        @CompileStatic(extensions = 'groovy.typecheckers.MonadicChecker')
+        class Calc {
+            static int sum() {
+                DO(a in Optional.of(2),
+                   b in Optional.of(3)) {
+                    Optional.of(a + b)
+                }.get()
+            }
+        }
+        assert Calc.sum() == 5
+        // end::do_static[]
+        '''
+    }
+}
diff --git a/subprojects/groovy-macro-library/build.gradle 
b/subprojects/groovy-macro-library/src/test/groovy/fj/F.groovy
similarity index 69%
copy from subprojects/groovy-macro-library/build.gradle
copy to subprojects/groovy-macro-library/src/test/groovy/fj/F.groovy
index f334e87fc9..faa6908b1c 100644
--- a/subprojects/groovy-macro-library/build.gradle
+++ b/subprojects/groovy-macro-library/src/test/groovy/fj/F.groovy
@@ -16,20 +16,14 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-plugins {
-    id 'org.apache.groovy-library'
-}
+package fj
 
-dependencies {
-    implementation rootProject
-    implementation projects.groovyMacro
-    testImplementation projects.groovyTest
+/**
+ * Test-only stand-in for Functional Java's {@code fj.F} function interface,
+ * so the name-keyed carrier path can be exercised without depending on the
+ * (unmaintained) Functional Java library. Mirrors its single-abstract-method
+ * shape so the closure-to-SAM coercion is genuinely tested.
+ */
+interface F<A, B> {
+    B f(A a)
 }
-
-groovyLibrary {
-    optionalModule()
-    withoutBinaryCompatibilityChecks()
-    moduleDescriptor {
-        extensionClasses = 'org.apache.groovy.macrolib.MacroLibGroovyMethods'
-    }
-}
\ No newline at end of file
diff --git 
a/subprojects/groovy-macro-library/src/test/groovy/fj/data/Option.groovy 
b/subprojects/groovy-macro-library/src/test/groovy/fj/data/Option.groovy
new file mode 100644
index 0000000000..2d63120cd1
--- /dev/null
+++ b/subprojects/groovy-macro-library/src/test/groovy/fj/data/Option.groovy
@@ -0,0 +1,51 @@
+/*
+ *  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 fj.data
+
+import fj.F
+
+/**
+ * Test-only stand-in for Functional Java's {@code fj.data.Option}. It uses 
FJ's
+ * conventions deliberately: {@code bind}/{@code map} (not {@code flatMap}),
+ * arguments typed {@code fj.F} (neither {@code Closure} nor
+ * {@code java.util.function.Function}), and concrete subclasses so the 
registry's
+ * supertype walk ({@code fj.data.Some} &rarr; {@code fj.data.Option}) is 
exercised.
+ */
+abstract class Option<A> {
+    abstract boolean defined()
+    abstract A get()
+
+    static <A> Option<A> some(A a) { new Some<A>(a) }
+    static <A> Option<A> none() { new None<A>() }
+
+    Option bind(F f) { defined() ? (Option) f.f(get()) : this }
+    Option map(F f) { defined() ? some(f.f(get())) : this }
+}
+
+final class Some<A> extends Option<A> {
+    private final A v
+    Some(A v) { this.v = v }
+    boolean defined() { true }
+    A get() { v }
+}
+
+final class None<A> extends Option<A> {
+    boolean defined() { false }
+    A get() { throw new NoSuchElementException() }
+}
diff --git 
a/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/DoMacroTest.groovy
 
b/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/DoMacroTest.groovy
new file mode 100644
index 0000000000..59253e4886
--- /dev/null
+++ 
b/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/DoMacroTest.groovy
@@ -0,0 +1,217 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.macrolib
+
+import org.codehaus.groovy.ast.CodeVisitorSupport
+import org.codehaus.groovy.ast.builder.AstBuilder
+import org.codehaus.groovy.ast.expr.ClosureExpression
+import org.codehaus.groovy.ast.expr.MethodCallExpression
+import org.codehaus.groovy.ast.stmt.BlockStatement
+import org.codehaus.groovy.control.CompilePhase
+import org.codehaus.groovy.control.MultipleCompilationErrorsException
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+import static org.junit.jupiter.api.Assertions.fail
+
+/**
+ * Tests the {@code DO} macro: it rewrites {@code DO(name in expr, ...) { body 
}}
+ * into the nested {@code Comprehensions.bind} chain, and rejects malformed
+ * comprehensions with sourced compile errors.
+ */
+final class DoMacroTest {
+
+    @Test
+    void singleGenerator_optional() {
+        assertScript '''
+            assert DO(a in Optional.of(2)) { Optional.of(a + 1) }.get() == 3
+        '''
+    }
+
+    @Test
+    void multiGenerator_optional_and_shortCircuit() {
+        assertScript '''
+            assert DO(a in Optional.of(2),
+                      b in Optional.of(3)) { Optional.of(a + b) }.get() == 5
+
+            assert !DO(a in Optional.empty(),
+                       b in Optional.of(3)) { Optional.of(a + b) }.present
+        '''
+    }
+
+    @Test
+    void laterGeneratorSeesEarlierBinding() {
+        assertScript '''
+            // b's source expression depends on a -> proves generator scoping
+            assert DO(a in Optional.of(2),
+                      b in Optional.of(a + 10)) { Optional.of(a + b) }.get() 
== 14
+        '''
+    }
+
+    @Test
+    void streamCartesian() {
+        assertScript '''
+            import java.util.stream.Stream
+            def r = DO(a in Stream.of(1, 2),
+                       b in Stream.of(10, 20)) { Stream.of(a + b) }
+            assert r.toList() == [11, 21, 12, 22]
+        '''
+    }
+
+    @Test
+    void awaitable_usesThenCompose() {
+        assertScript '''
+            import groovy.concurrent.Awaitable
+            import static org.apache.groovy.runtime.async.AsyncSupport.await
+
+            def result = DO(a in Awaitable.of(2),
+                            b in Awaitable.of(3)) { Awaitable.of(a + b) }
+            assert await(result) == 5
+        '''
+    }
+
+    @Test
+    void structuralUserType() {
+        assertScript '''
+            import java.util.function.Function
+
+            class Box {
+                final Object v
+                Box(Object v) { this.v = v }
+                Box flatMap(Function f) { (Box) f.apply(v) }
+                Box map(Function f) { new Box(f.apply(v)) }
+            }
+
+            def r = DO(a in new Box(2),
+                       b in new Box(40)) { new Box(a + b) }
+            assert r.v == 42
+        '''
+    }
+
+    @Test
+    void monadicAnnotatedUserType() {
+        assertScript '''
+            import groovy.transform.Monadic
+            import java.util.function.Function
+
+            @Monadic(bind = 'chain', map = 'transform')
+            class Res {
+                final Object v
+                Res(Object v) { this.v = v }
+                Res chain(Function f) { (Res) f.apply(v) }
+                Res transform(Function f) { new Res(f.apply(v)) }
+            }
+
+            def r = DO(a in new Res(2),
+                       b in new Res(3)) { new Res(a * b) }
+            assert r.v == 6
+        '''
+    }
+
+    @Test
+    void structuralCarrierWithUntypedFlatMapWorksAtRuntime() {
+        // Regression: the dispatcher's adaptClosure used to wrap the closure 
as a
+        // java.util.function.Function when the carrier's flatMap declared an
+        // Object parameter (the untyped-Groovy default), because
+        // pt.isAssignableFrom(Function.class) is true for pt == Object. The
+        // user's body typically calls c.call(x) expecting Closure semantics,
+        // which fails against a Function wrapper. Untyped Object must now fall
+        // through to asType, leaving the Closure unchanged.
+        assertScript '''
+            class Box {
+                final int v
+                Box(int v) { this.v = v }
+                def flatMap(c) { c.call(v) }
+                def map(c) { new Box(c.call(v)) }
+            }
+            def r = DO(a in new Box(2), b in new Box(3)) { new Box(a + b) }
+            assert r.v == 5
+        '''
+    }
+
+    @Test
+    void rejectsGeneratorWithoutIn() {
+        assertCompileError('DO(a, b in Optional.of(1)) { Optional.of(a) }',
+            "DO generator must have the form 'name in expression'")
+    }
+
+    @Test
+    void rejectsMissingClosureBody() {
+        assertCompileError('def x = DO(a in Optional.of(1))', 'DO requires')
+    }
+
+    @Test
+    void rejectsBodyClosureWithParameters() {
+        assertCompileError('DO(a in Optional.of(1)) { x -> Optional.of(x) }',
+            'must not declare parameters')
+    }
+
+    @Test
+    void syntheticNodesCarrySourcePositions() {
+        // Macro-emitted nodes default to (line, col) = (-1, -1); positionless
+        // nodes are silently dropped by some STC paths (notably
+        // StaticTypeCheckingVisitor.addStaticTypeError), and they break IDE
+        // navigation. Every synthetic Comprehensions.bind call and lambda the
+        // macro creates must inherit positions from the user's source.
+        def script = '''
+def r = DO(a in Optional.of(2),
+           b in Optional.of(3)) { Optional.of(a + b) }
+'''.trim()
+        def nodes = new 
AstBuilder().buildFromString(CompilePhase.SEMANTIC_ANALYSIS, false, script)
+        def bindCalls = []
+        def lambdas = []
+        def visitor = new CodeVisitorSupport() {
+            @Override
+            void visitMethodCallExpression(MethodCallExpression call) {
+                if (call.methodAsString == 'bind') bindCalls << call
+                super.visitMethodCallExpression(call)
+            }
+            @Override
+            void visitClosureExpression(ClosureExpression closure) {
+                lambdas << closure
+                super.visitClosureExpression(closure)
+            }
+        }
+        nodes.findAll { it instanceof BlockStatement }.each { 
it.visit(visitor) }
+
+        // Two generators => two nested Comprehensions.bind calls, both 
positionful.
+        assert bindCalls.size() == 2
+        bindCalls.each { call ->
+            assert call.lineNumber > 0 : "synthetic bind call missing line: 
${call.text}"
+            assert call.columnNumber > 0
+        }
+        // The macro emits one lambda per generator (the user's body closure is
+        // consumed for its statements, not its ClosureExpression); both
+        // synthetic lambdas must carry positions.
+        assert lambdas.size() == 2
+        lambdas.each { lambda ->
+            assert lambda.lineNumber > 0 : "synthetic lambda missing line"
+            assert lambda.columnNumber > 0
+        }
+    }
+
+    private static void assertCompileError(String script, String 
expectedMessage) {
+        try {
+            new GroovyShell().parse(script)
+            fail("Expected a compilation error containing: $expectedMessage")
+        } catch (MultipleCompilationErrorsException e) {
+            assert e.message.contains(expectedMessage)
+        }
+    }
+}
diff --git 
a/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/DoStaticTest.groovy
 
b/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/DoStaticTest.groovy
new file mode 100644
index 0000000000..93ae725d2e
--- /dev/null
+++ 
b/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/DoStaticTest.groovy
@@ -0,0 +1,275 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.macrolib
+
+import org.codehaus.groovy.control.MultipleCompilationErrorsException
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+import static org.junit.jupiter.api.Assertions.fail
+
+/**
+ * The {@code DO} macro under {@code @CompileStatic} with the
+ * {@code groovy.typecheckers.MonadicChecker} extension: the extension
+ * (a) types the generator closure parameter as the carrier element type so the
+ * body type-checks, (b) restores the comprehension result type so downstream
+ * use type-checks, and (c) rejects non-participating carriers at compile time.
+ */
+final class DoStaticTest {
+
+    private static final String CS = 
"@groovy.transform.CompileStatic(extensions='groovy.typecheckers.MonadicChecker')"
+
+    @Test
+    void boundParameterIsTypedAsElementType_optional() {
+        assertScript """
+            $CS
+            class C {
+                static int run() {
+                    def r = DO(a in Optional.of(2),
+                               b in Optional.of(3)) { Optional.of(a.intValue() 
+ b.intValue()) }
+                    r.get()
+                }
+            }
+            assert C.run() == 5
+        """
+    }
+
+    @Test
+    void dependentGeneratorTypeChecks() {
+        assertScript """
+            $CS
+            class C {
+                static int run() {
+                    def r = DO(a in Optional.of(2),
+                               b in Optional.of(a + 10)) { Optional.of(a + b) }
+                    r.get()
+                }
+            }
+            assert C.run() == 14
+        """
+    }
+
+    @Test
+    void streamResultTypeChecksDownstream() {
+        assertScript """
+            import java.util.stream.Stream
+            $CS
+            class C {
+                static List<Integer> run() {
+                    def r = DO(a in Stream.of(1, 2),
+                               b in Stream.of(10, 20)) { Stream.of(a + b) }
+                    r.toList()
+                }
+            }
+            assert C.run() == [11, 21, 12, 22]
+        """
+    }
+
+    @Test
+    void awaitableUnderCompileStatic() {
+        assertScript """
+            import groovy.concurrent.Awaitable
+            import static org.apache.groovy.runtime.async.AsyncSupport.await
+            $CS
+            class C {
+                static int run() {
+                    def r = DO(a in Awaitable.of(2),
+                               b in Awaitable.of(3)) { Awaitable.of(a + b) }
+                    await(r)
+                }
+            }
+            assert C.run() == 5
+        """
+    }
+
+    @Test
+    void monadicAnnotatedTypeUnderCompileStatic() {
+        assertScript """
+            import groovy.transform.Monadic
+            import java.util.function.Function
+
+            @Monadic(bind = 'chain', map = 'transform')
+            class Res {
+                final Object v
+                Res(Object v) { this.v = v }
+                Res chain(Function f) { (Res) f.apply(v) }
+                Res transform(Function f) { new Res(f.apply(v)) }
+            }
+
+            $CS
+            class C {
+                static Object run() {
+                    def r = DO(a in new Res(2), b in new Res(3)) { new Res(a) }
+                    r.v
+                }
+            }
+            assert C.run() == 2
+        """
+    }
+
+    @Test
+    void rejectsNonParticipatingCarrierAtCompileTime() {
+        assertCompileError("""
+            $CS
+            class C {
+                static def run() {
+                    DO(a in new Object()) { Optional.of(a) }
+                }
+            }
+        """, 'does not participate in monadic comprehensions')
+    }
+
+    @Test
+    void rejectsBareBodyAtCompileTime() {
+        // Body returns a bare value; the dispatcher's erased 
(Object,Closure):Object
+        // signature lets STC accept this, but Optional.flatMap would fail at 
runtime.
+        assertCompileError("""
+            $CS
+            class C {
+                static def run() {
+                    DO(a in Optional.of(2)) { a + 1 }
+                }
+            }
+        """, 'must yield java.util.Optional')
+    }
+
+    @Test
+    void rejectsCrossCarrierBodyAtCompileTime() {
+        // Outer carrier Optional, body produces a Stream — well-typed against 
the
+        // erased dispatcher but rejected by Optional.flatMap at runtime.
+        assertCompileError("""
+            import java.util.stream.Stream
+            $CS
+            class C {
+                static def run() {
+                    DO(a in Optional.of(2)) { Stream.of(a) }
+                }
+            }
+        """, 'Mixing carriers in a comprehension is not supported')
+    }
+
+    @Test
+    void rejectsCrossCarrierInNestedDoAtCompileTime() {
+        // The classic gotcha: outer Optional, inner Stream — the outer bind's
+        // closure ends up yielding Stream, contradicting the receiver 
Optional.
+        assertCompileError("""
+            import java.util.stream.Stream
+            $CS
+            class C {
+                static def run() {
+                    DO(a in Optional.of(2),
+                       b in Stream.of(a, a + 10)) { Stream.of(b) }
+                }
+            }
+        """, 'Mixing carriers in a comprehension is not supported')
+    }
+
+    @Test
+    void rejectsCarrierWithOnly2ArgFlatMapAtCompileTime() {
+        // Regression: hasMethodNamed used to count any 'flatMap' regardless of
+        // arity, so a 2-arg flatMap satisfied participation statically and
+        // then failed at runtime in the dispatcher (which needs a single-arg
+        // method). Now aligned with the runtime rule.
+        assertCompileError("""
+            class Box {
+                final int v
+                Box(int v) { this.v = v }
+                Box flatMap(int extra, Closure c) { (Box) c.call(v + extra) }
+            }
+            $CS
+            class C {
+                static def run() {
+                    DO(a in new Box(2)) { new Box(((Integer) a) + 1) }
+                }
+            }
+        """, 'does not participate in monadic comprehensions')
+    }
+
+    @Test
+    void monadicAnnotationOnSuperclassAcceptedAtCompileTime() {
+        // Regression: the static @Monadic check used to look only at the 
type's
+        // own annotations; the runtime walks superclasses. Without the walk,
+        // a SubRes whose @Monadic lives on BaseRes would be rejected here yet
+        // accepted at runtime.
+        assertScript """
+            import groovy.transform.Monadic
+
+            @Monadic
+            class BaseRes {
+                final Object v
+                BaseRes(Object v) { this.v = v }
+                BaseRes flatMap(Closure c) { (BaseRes) c.call(v) }
+                BaseRes map(Closure c) { new BaseRes(c.call(v)) }
+            }
+            class SubRes extends BaseRes {
+                SubRes(Object v) { super(v) }
+            }
+
+            $CS
+            class C {
+                static Object run() {
+                    def r = DO(a in new SubRes(2),
+                               b in new SubRes(3)) { new SubRes(b) }
+                    r.v
+                }
+            }
+            assert C.run() == 3
+        """
+    }
+
+    @Test
+    void monadicAnnotationOnInterfaceAcceptedAtCompileTime() {
+        // Regression: @Monadic declared on an interface was rejected by the
+        // static check (which didn't walk interfaces); the runtime accepts it.
+        assertScript """
+            import groovy.transform.Monadic
+
+            @Monadic
+            interface IRes {
+                IRes flatMap(Closure c)
+                IRes map(Closure c)
+            }
+            class Holder implements IRes {
+                final Object v
+                Holder(Object v) { this.v = v }
+                IRes flatMap(Closure c) { (IRes) c.call(v) }
+                IRes map(Closure c) { new Holder(c.call(v)) }
+            }
+
+            $CS
+            class C {
+                static Object run() {
+                    def r = DO(a in new Holder(2),
+                               b in new Holder(3)) { new Holder(b) }
+                    ((Holder) r).v
+                }
+            }
+            assert C.run() == 3
+        """
+    }
+
+    private static void assertCompileError(String script, String 
expectedMessage) {
+        try {
+            new GroovyShell().parse(script)
+            fail("Expected a compilation error containing: $expectedMessage")
+        } catch (MultipleCompilationErrorsException e) {
+            assert e.message.contains(expectedMessage)
+        }
+    }
+}
diff --git 
a/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/FunctionalJavaCarrierTest.groovy
 
b/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/FunctionalJavaCarrierTest.groovy
new file mode 100644
index 0000000000..ccba099edb
--- /dev/null
+++ 
b/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/FunctionalJavaCarrierTest.groovy
@@ -0,0 +1,69 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.macrolib
+
+import fj.data.Option
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertFalse
+
+/**
+ * Functional Java participates via the name-keyed allow-list (it uses
+ * {@code bind}/{@code map} with {@code fj.F} arguments, so structural and
+ * {@code @Monadic} cannot apply). Exercises the registry supertype walk
+ * ({@code fj.data.Some} &rarr; {@code fj.data.Option}) and the general
+ * closure-to-SAM coercion (closure &rarr; {@code fj.F}).
+ */
+final class FunctionalJavaCarrierTest {
+
+    @Test
+    void composesAndShortCircuits() {
+        def sum = DO(a in Option.some(2),
+                     b in Option.some(3)) {
+            Option.some(a + b)
+        }
+        assertEquals(5, sum.get())
+
+        def shorted = DO(a in Option.none(),
+                         b in Option.some(3)) {
+            Option.some(b)
+        }
+        assertFalse(shorted.defined())
+    }
+
+    @Test
+    void underCompileStaticViaMonadicChecker() {
+        assertScript '''
+            import fj.data.Option
+
+            
@groovy.transform.CompileStatic(extensions='groovy.typecheckers.MonadicChecker')
+            class C {
+                static int run() {
+                    DO(a in Option.some(2),
+                       b in Option.some(3)) {
+                        Option.some(a + b)
+                    }.get()
+                }
+            }
+            assert C.run() == 5
+        '''
+    }
+}
diff --git 
a/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/MonadicComprehensionsTest.groovy
 
b/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/MonadicComprehensionsTest.groovy
new file mode 100644
index 0000000000..447862cf23
--- /dev/null
+++ 
b/subprojects/groovy-macro-library/src/test/groovy/org/apache/groovy/macrolib/MonadicComprehensionsTest.groovy
@@ -0,0 +1,191 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.groovy.macrolib
+
+import groovy.concurrent.Awaitable
+import groovy.concurrent.DataflowVariable
+import groovy.transform.Monadic
+import org.apache.groovy.runtime.Comprehensions
+import org.apache.groovy.runtime.async.AsyncSupport
+import org.junit.jupiter.api.Test
+
+import java.util.concurrent.CompletableFuture
+import java.util.function.Function
+import java.util.stream.Stream
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertThrows
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Verifies the {@link Comprehensions} bind/map dispatcher delivers the
+ * monadic-comprehension semantics across every carrier in the allow-list plus
+ * structural, Closure-surface, and {@code @Monadic} participation.
+ *
+ * Each test writes the nested {@code Comprehensions.bind} chain directly 
&mdash;
+ * the shape the {@code DO} macro emits:
+ * <pre>
+ *   DO(x in m1, y in f(x)) { body }
+ *     ==&gt;  Comprehensions.bind(m1) { x -&gt; Comprehensions.bind(f(x)) { y 
-&gt; body } }
+ * </pre>
+ */
+final class MonadicComprehensionsTest {
+
+    @Test
+    void optional_composes_and_short_circuits() {
+        def sum = Comprehensions.bind(Optional.of(2)) { a ->
+            Comprehensions.bind(Optional.of(3)) { b ->
+                Optional.of(a + b)
+            }
+        }
+        assertEquals(Optional.of(5), sum)
+
+        def shorted = Comprehensions.bind(Optional.empty()) { a ->
+            Comprehensions.bind(Optional.of(3)) { b -> Optional.of(a + b) }
+        }
+        assertEquals(Optional.empty(), shorted)
+    }
+
+    @Test
+    void stream_cartesian_composition() {
+        def result = Comprehensions.bind(Stream.of(1, 2)) { a ->
+            Comprehensions.bind(Stream.of(10, 20)) { b ->
+                Stream.of(a + b)
+            }
+        }
+        assertEquals([11, 21, 12, 22], ((Stream) result).toList())
+    }
+
+    @Test
+    void completableFuture_composes() {
+        def fut = Comprehensions.bind(CompletableFuture.completedFuture(2)) { 
a ->
+            Comprehensions.bind(CompletableFuture.completedFuture(3)) { b ->
+                CompletableFuture.completedFuture(a + b)
+            }
+        }
+        assertEquals(5, ((CompletableFuture) fut).get())
+    }
+
+    @Test
+    void awaitable_composes_via_thenCompose() {
+        // Awaitable.then is map; thenCompose is bind.
+        def result = Comprehensions.bind(Awaitable.of(2)) { a ->
+            Comprehensions.bind(Awaitable.of(3)) { b ->
+                Awaitable.of(a + b)
+            }
+        }
+        assertEquals(5, AsyncSupport.await((Awaitable) result))
+    }
+
+    @Test
+    void dataflowVariable_composes() {
+        def x = new DataflowVariable()
+        def y = new DataflowVariable()
+        x.bind(10)
+        y.bind(5)
+        def sum = Comprehensions.bind(x) { a ->
+            Comprehensions.bind(y) { b ->
+                Awaitable.of(a + b)
+            }
+        }
+        assertEquals(15, AsyncSupport.await((Awaitable) sum))
+    }
+
+    @Test
+    void structural_participation_no_annotation() {
+        def boxed = Comprehensions.bind(new Box(2)) { a ->
+            Comprehensions.bind(new Box(3)) { b ->
+                new Box(a + b)
+            }
+        }
+        assertEquals(5, ((Box) boxed).v)
+    }
+
+    @Test
+    void closure_surface_participation() {
+        // The bind method accepts a Closure rather than a Function.
+        def boxed = Comprehensions.bind(new ClosureBox(2)) { a ->
+            Comprehensions.bind(new ClosureBox(3)) { b ->
+                new ClosureBox(a + b)
+            }
+        }
+        assertEquals(5, ((ClosureBox) boxed).v)
+    }
+
+    @Test
+    void monadic_annotation_with_name_overrides() {
+        def res = Comprehensions.bind(new Res(2)) { a ->
+            Comprehensions.bind(new Res(3)) { b ->
+                new Res(a + b)
+            }
+        }
+        assertEquals(5, ((Res) res).v)
+    }
+
+    @Test
+    void map_role_uses_map_name() {
+        assertEquals(Optional.of(3), Comprehensions.map(Optional.of(2)) { it + 
1 })
+        def r = Comprehensions.map(new Res(2)) { it * 10 } // map -> 
'transform'
+        assertEquals(20, ((Res) r).v)
+    }
+
+    @Test
+    void non_participating_type_fails_with_a_precise_message() {
+        def ex = assertThrows(IllegalArgumentException) {
+            Comprehensions.bind(new Plain(1)) { a -> new Plain(a) }
+        }
+        assertTrue(ex.message.contains(Plain.name))
+        assertTrue(ex.message.contains('does not participate'))
+        assertTrue(ex.message.contains('@Monadic'))
+    }
+}
+
+/** Structural carrier: conventional flatMap/map taking a 
java.util.function.Function. */
+class Box {
+    final Object v
+    Box(Object v) { this.v = v }
+    Box flatMap(Function f) { (Box) f.apply(v) }
+    Box map(Function f) { new Box(f.apply(v)) }
+    String toString() { "Box($v)" }
+}
+
+/** Structural carrier whose bind/map take a Closure rather than a Function. */
+class ClosureBox {
+    final Object v
+    ClosureBox(Object v) { this.v = v }
+    ClosureBox flatMap(Closure f) { (ClosureBox) f.call(v) }
+    ClosureBox map(Closure f) { new ClosureBox(f.call(v)) }
+    String toString() { "ClosureBox($v)" }
+}
+
+/** Opt-in carrier with non-conventional method names declared via @Monadic. */
+@Monadic(bind = 'chain', map = 'transform')
+class Res {
+    final Object v
+    Res(Object v) { this.v = v }
+    Res chain(Function f) { (Res) f.apply(v) }
+    Res transform(Function f) { new Res(f.apply(v)) }
+    String toString() { "Res($v)" }
+}
+
+/** A type that does not participate at all (negative case). */
+class Plain {
+    final Object v
+    Plain(Object v) { this.v = v }
+}
diff --git 
a/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/MonadicChecker.groovy
 
b/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/MonadicChecker.groovy
new file mode 100644
index 0000000000..50f94ba77a
--- /dev/null
+++ 
b/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/MonadicChecker.groovy
@@ -0,0 +1,282 @@
+/*
+ *  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 groovy.typecheckers
+
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.GenericsType
+import org.codehaus.groovy.ast.MethodNode
+import org.codehaus.groovy.ast.expr.ClosureExpression
+import org.codehaus.groovy.ast.expr.MethodCallExpression
+import org.apache.groovy.runtime.MonadicCarrierRegistry
+import org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport
+import org.codehaus.groovy.transform.stc.StaticTypesMarker
+
+import static org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE
+import static org.codehaus.groovy.ast.ClassHelper.make
+import static 
org.codehaus.groovy.ast.tools.GenericsUtils.makeClassSafeWithGenerics
+import static org.objectweb.asm.Opcodes.ACC_BRIDGE
+
+/**
+ * Teaches {@code @CompileStatic}/{@code @TypeChecked} about the {@code DO} 
macro's
+ * desugared output: calls to {@code 
org.apache.groovy.runtime.Comprehensions.bind}
+ * and {@code .map}, declared {@code (Object, Closure):Object}.
+ *
+ * Three jobs:
+ * <ul>
+ *   <li><b>Enforce receiver shape</b>: the carrier must participate 
(allow-list,
+ *       structural {@code flatMap}/{@code map}, or {@code @Monadic}); 
otherwise a
+ *       precise compile error naming the type and the missing shape.</li>
+ *   <li><b>Enforce closure-return shape</b> (trusted carriers only &mdash; 
registry
+ *       or {@code @Monadic}, not structural): {@code bind}'s closure must 
yield the
+ *       <em>same</em> carrier (catches a bare body or a cross-carrier body 
inside
+ *       {@code DO}, which the erased dispatcher signature otherwise lets 
through);
+ *       {@code map}'s closure must <em>not</em> yield the same carrier (the
+ *       {@code M<M<T>>} foot-gun for hand-written {@code 
Comprehensions.map}).</li>
+ *   <li><b>Assist inference</b>: type the generator closure's parameter as the
+ *       carrier's element type (so the body type-checks), and restore the
+ *       comprehension's result type (so {@code .get()}/nesting type-check) 
instead
+ *       of the erased {@code Object} the dispatcher signature would 
yield.</li>
+ * </ul>
+ *
+ * Closure-parameter typing works by pre-setting {@code CLOSURE_ARGUMENTS} on 
the
+ * closure node, which {@code 
StaticTypeCheckingVisitor.getTypeFromClosureArguments}
+ * consults by parameter name &mdash; independent of the {@code Closure<?>} 
parameter
+ * not being a SAM type.
+ *
+ * Activate with {@code 
@CompileStatic(extensions='groovy.typecheckers.MonadicChecker')}.
+ */
+class MonadicChecker extends 
GroovyTypeCheckingExtensionSupport.TypeCheckingDSL {
+
+    private static final String DISPATCHER = 
'org.apache.groovy.runtime.Comprehensions'
+
+    @Override
+    Object run() {
+        // Fires after method selection (carrier argument already typed) but 
before
+        // the generator closure body is visited: the window to type the 
closure param.
+        onMethodSelection { expr, MethodNode target ->
+            if (!isDispatcherCall(expr, target)) return
+            MethodCallExpression call = (MethodCallExpression) expr
+            def args = call.arguments.expressions
+            def carrierType = safeType(args[0])
+            String role = call.methodAsString
+
+            if (carrierType == null || !participates(carrierType)) {
+                addStaticTypeError(
+                    "Type ${typeName(carrierType)} does not participate in 
monadic comprehensions (DO): " +
+                    "no ${role == 'bind' ? "bind (flatMap-shaped)" : 'map'} 
method " +
+                    "(not in the standard carrier allow-list, has no 
structural " +
+                    "'${role == 'bind' ? 'flatMap' : 'map'}' method, and is 
not annotated @Monadic)",
+                    args[0])
+                return
+            }
+
+            def closure = args.find { it instanceof ClosureExpression } as 
ClosureExpression
+            if (closure != null) {
+                ClassNode elem = elementType(carrierType)
+                closure.putNodeMetaData(StaticTypesMarker.CLOSURE_ARGUMENTS, 
[elem] as ClassNode[])
+            }
+        }
+
+        // The dispatcher returns erased Object; restore the comprehension's 
real type.
+        afterMethodCall { call ->
+            if (!(call instanceof MethodCallExpression)) return
+            MethodNode target = 
call.getNodeMetaData(StaticTypesMarker.DIRECT_METHOD_CALL_TARGET)
+            if (!isDispatcherCall(call, target)) return
+            def args = call.arguments.expressions
+            def carrierType = safeType(args[0])
+            if (carrierType == null) return
+            def closure = args.find { it instanceof ClosureExpression } as 
ClosureExpression
+            ClassNode produced = closureReturnType(closure)
+
+            // Closure-return shape check (trusted carriers only). The 
dispatcher
+            // signature is (Object, Closure):Object — STC cannot see that the
+            // body must yield the same carrier (bind) or a non-carrier (map);
+            // restore the contract here. Skipped for structural-only carriers
+            // (intentionally permissive, like participates()).
+            //
+            // Anchor at the closure (the actual offender) when present; the DO
+            // macro propagates source positions onto its synthetic lambda, so
+            // STC's addStaticTypeError (which drops positionless nodes) will
+            // surface this. Fall through to args[0] for the hand-written
+            // Comprehensions.bind/map shape where the closure may not be a
+            // literal at this argument slot.
+            String role = call.methodAsString
+            String shape = shapeMsg(role, carrierType, produced)
+            if (shape) addStaticTypeError(shape, closure ?: args[0])
+
+            ClassNode result
+            if (role == 'bind') {
+                // closure yields M<B>; bind yields the same carrier
+                result = produced ?: carrierType
+            } else {
+                // map: closure yields B; result is M<B>
+                ClassNode b = produced ?: OBJECT_TYPE
+                result = 
makeClassSafeWithGenerics(carrierType.plainNodeReference, new GenericsType(b))
+            }
+            storeType(call, result)
+        }
+    }
+
+    /**
+     * Diagnostic for the dispatcher closure-return contract, or null if 
acceptable.
+     * Tolerates unknown returns (null/Object); only flags when the carrier 
mismatch
+     * is statically demonstrable and the receiver is a trusted (registry- or
+     * {@code @Monadic}-keyed) carrier.
+     */
+    private String shapeMsg(String role, ClassNode receiver, ClassNode 
produced) {
+        if (produced == null || produced == OBJECT_TYPE) return null
+        String recv = trustedCarrierName(receiver)
+        if (recv == null) return null
+        String ret = trustedCarrierName(produced)
+        if (role == 'bind') {
+            if (ret == null) {
+                return "Closure passed to Comprehensions.bind on ${recv} must 
yield ${recv}; " +
+                    "got ${typeName(produced)} (not a carrier). In a DO 
comprehension, the body " +
+                    "must produce the same carrier (e.g. ${recv}.of(...))."
+            }
+            if (ret != recv) {
+                return "Closure passed to Comprehensions.bind on ${recv} must 
yield ${recv}; " +
+                    "got ${ret}. Mixing carriers in a comprehension is not 
supported."
+            }
+            return null
+        }
+        // role == 'map'
+        if (ret == recv) {
+            return "Closure passed to Comprehensions.map on ${recv} returns a 
${recv}, " +
+                "producing ${recv}<${recv}<...>>; use Comprehensions.bind 
instead."
+        }
+        null
+    }
+
+    /**
+     * The canonical carrier name &mdash; the key for same-carrier comparison 
&mdash;
+     * for the given type, restricted to <em>trusted</em> participation paths
+     * (registry allow-list, {@code @Monadic}). Returns {@code null} for
+     * structural-only or non-carrier types; structural participation is
+     * intentionally permissive and not asserted against.
+     */
+    private String trustedCarrierName(ClassNode cn) {
+        if (cn == null) return null
+        ClassNode bare = cn.redirect() ?: cn
+        for (e in MonadicCarrierRegistry.entries()) {
+            if (assignableTo(bare, make(e.carrier()))) return 
make(e.carrier()).name
+        }
+        for (e in MonadicCarrierRegistry.namedEntries()) {
+            if (assignableTo(bare, make(e.carrierName()))) return 
e.carrierName()
+        }
+        // @Monadic walks super + interfaces, mirroring the runtime
+        ClassNode mon = classWithMonadic(bare)
+        mon?.name
+    }
+
+    private boolean isDispatcherCall(expr, MethodNode target) {
+        expr instanceof MethodCallExpression &&
+            expr.methodAsString in ['bind', 'map'] &&
+            target?.declaringClass?.name == DISPATCHER
+    }
+
+    private ClassNode safeType(expr) {
+        try { getType(expr) } catch (ignored) { null }
+    }
+
+    private static String typeName(ClassNode cn) {
+        cn == null ? '<unknown>' : cn.toString(false)
+    }
+
+    private boolean participates(ClassNode cn) {
+        ClassNode bare = cn.redirect() ?: cn
+        // 1. standard allow-list (shared with the runtime dispatcher), Class- 
and name-keyed
+        if (MonadicCarrierRegistry.entries().any { assignableTo(bare, 
make(it.carrier())) }) return true
+        if (MonadicCarrierRegistry.namedEntries().any { assignableTo(bare, 
make(it.carrierName())) }) return true
+        // 2. structural (flatMap covers bind; map covers the map role); 
arity-1 only
+        // — aligns with the runtime dispatcher's findSingleArgMethod
+        if (hasSingleArgMethod(bare, 'flatMap') || hasSingleArgMethod(bare, 
'map')) return true
+        // 3. @Monadic opt-in (matched by simple name, like 
@Reducer/@Associative);
+        // walk super + interfaces, mirroring the runtime dispatcher
+        return classWithMonadic(bare) != null
+    }
+
+    private boolean assignableTo(ClassNode cn, ClassNode t) {
+        cn == t || cn.isDerivedFrom(t) || cn.implementsInterface(t)
+    }
+
+    /**
+     * True iff any class in the type's superclass-then-interfaces walk 
declares a
+     * single-argument, non-bridge, non-synthetic method with the given name. 
The
+     * arity/bridge/synthetic filter aligns the static check with the runtime
+     * dispatcher's {@code findSingleArgMethod}: without it, a 2-arg
+     * {@code flatMap(state, fn)} would pass participation here yet fail at 
runtime.
+     */
+    private boolean hasSingleArgMethod(ClassNode cn, String name) {
+        for (ClassNode c = cn; c != null && c != OBJECT_TYPE; c = 
c.superClass) {
+            if (hasSingleArgIn(c, name)) return true
+            if (c.interfaces?.any { hasSingleArgIn(it, name) }) return true
+        }
+        false
+    }
+
+    private static boolean hasSingleArgIn(ClassNode cn, String name) {
+        cn.getMethods(name)?.any { isSingleArgUserMethod(it) } ?: false
+    }
+
+    private static boolean isSingleArgUserMethod(MethodNode m) {
+        m.parameters?.length == 1 && !m.isSynthetic() && (m.modifiers & 
ACC_BRIDGE) == 0
+    }
+
+    /**
+     * Walks superclasses and their direct interfaces looking for a
+     * {@code @Monadic} annotation (simple-name match, in the manner of
+     * {@code @Reducer}/{@code @Associative}). Returns the {@code ClassNode}
+     * that carries the annotation, or {@code null} if none. Mirrors the
+     * runtime dispatcher's {@code monadicMethodName} walk.
+     */
+    private static ClassNode classWithMonadic(ClassNode start) {
+        for (ClassNode c = start; c != null && c.name != 'java.lang.Object'; c 
= c.superClass) {
+            if (hasMonadicAnno(c)) return c
+            ClassNode[] ifaces = c.interfaces
+            if (ifaces != null) {
+                for (ClassNode i : ifaces) {
+                    if (hasMonadicAnno(i)) return i
+                }
+            }
+        }
+        null
+    }
+
+    private static boolean hasMonadicAnno(ClassNode cn) {
+        cn.annotations?.any { it.classNode?.nameWithoutPackage == 'Monadic' } 
?: false
+    }
+
+    private ClassNode elementType(ClassNode carrier) {
+        def gts = carrier?.genericsTypes
+        (gts && gts.length > 0 && gts[0].type) ? gts[0].type : OBJECT_TYPE
+    }
+
+    private ClassNode closureReturnType(ClosureExpression closure) {
+        if (closure == null) return null
+        // STC writes the inferred body-return type as metadata on the closure
+        // (not always reflected as Closure<R> generics on the closure's own
+        // type, especially for bare-expression bodies); consult both.
+        ClassNode inferred = 
closure.getNodeMetaData(StaticTypesMarker.INFERRED_RETURN_TYPE)
+        if (inferred != null) return inferred
+        def t = safeType(closure)
+        def gts = t?.genericsTypes
+        (gts && gts.length > 0 && gts[0].type) ? gts[0].type : null
+    }
+}
diff --git 
a/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/MonadicShapeChecker.groovy
 
b/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/MonadicShapeChecker.groovy
new file mode 100644
index 0000000000..c659964f50
--- /dev/null
+++ 
b/subprojects/groovy-typecheckers/src/main/groovy/groovy/typecheckers/MonadicShapeChecker.groovy
@@ -0,0 +1,279 @@
+/*
+ *  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 groovy.typecheckers
+
+import org.apache.groovy.lang.annotation.Incubating
+import org.apache.groovy.runtime.MonadicCarrierRegistry
+import org.apache.groovy.typecheckers.CheckingVisitor
+import org.codehaus.groovy.ast.AnnotationNode
+import org.codehaus.groovy.ast.ClassNode
+import org.codehaus.groovy.ast.MethodNode
+import org.codehaus.groovy.ast.expr.ArgumentListExpression
+import org.codehaus.groovy.ast.expr.ClassExpression
+import org.codehaus.groovy.ast.expr.ClosureExpression
+import org.codehaus.groovy.ast.expr.Expression
+import org.codehaus.groovy.ast.expr.MethodCallExpression
+import org.codehaus.groovy.ast.expr.MethodPointerExpression
+import org.codehaus.groovy.transform.stc.GroovyTypeCheckingExtensionSupport
+import org.codehaus.groovy.transform.stc.StaticTypesMarker
+
+import static org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE
+import static org.codehaus.groovy.ast.ClassHelper.make
+
+/**
+ * Compile-time lint for native monadic chains over the standard carrier
+ * allow-list ({@link MonadicCarrierRegistry}).
+ * <p>
+ * Sister to {@link MonadicChecker}: that one repairs erasure on calls routed
+ * through the {@code DO} macro's runtime dispatcher; this one catches shape
+ * bugs in <em>hand-written</em> {@code flatMap}/{@code map} (and
+ * {@code thenCompose}/{@code thenApply}/etc.) chains that the JDK generics
+ * cannot reject:
+ * <ul>
+ *   <li><b>{@code bind} returning a non-carrier</b> &mdash; e.g.
+ *       {@code Optional.flatMap { it + 1 }} where the JDK expects an
+ *       {@code Optional}. STC can silently let closures pass this gap.</li>
+ *   <li><b>{@code bind} returning a different carrier</b> &mdash; e.g.
+ *       {@code Stream.flatMap { Optional.of(it) }}. Almost certainly a 
bug.</li>
+ *   <li><b>{@code map} returning the same carrier</b> &mdash; e.g.
+ *       {@code Optional.map { Optional.of(it) }} producing
+ *       {@code Optional<Optional<T>>}; usually a missed {@code flatMap} (or
+ *       {@code thenCompose}).</li>
+ * </ul>
+ * Carriers, and the canonical names of their bind/map methods, are read
+ * entirely from {@link MonadicCarrierRegistry}; types annotated
+ * {@link groovy.transform.Monadic} also participate (matched by simple name,
+ * like {@code @Reducer}). Calls whose target is {@code Comprehensions} are
+ * skipped &mdash; they are {@link MonadicChecker}'s domain.
+ * <p>
+ * Two modes, selected via the extension option {@code mode}:
+ * <pre>
+ * // default (lenient): only flag high-confidence problems
+ * {@code @TypeChecked(extensions = 'groovy.typecheckers.MonadicShapeChecker')}
+ *
+ * // strict: also flag chains whose function return cannot be statically 
resolved
+ * {@code @TypeChecked(extensions = 
"groovy.typecheckers.MonadicShapeChecker(mode: 'strict')")}
+ * </pre>
+ *
+ * @since 6.0.0
+ * @see MonadicCarrierRegistry
+ * @see MonadicChecker
+ * @see groovy.transform.Monadic
+ */
+@Incubating
+class MonadicShapeChecker extends 
GroovyTypeCheckingExtensionSupport.TypeCheckingDSL {
+
+    /** The {@code DO}-macro dispatcher target; calls here are {@link 
MonadicChecker}'s. */
+    private static final String DISPATCHER = 
'org.apache.groovy.runtime.Comprehensions'
+
+    private boolean strict
+
+    @Override
+    Object run() {
+        strict = (options?.mode as String)?.equalsIgnoreCase('strict')
+        // Visit method bodies with a CheckingVisitor — same shape as
+        // CombinerChecker/PurityChecker; avoids the spotty dispatch context
+        // of the afterMethodCall hook in the rewritten DSL.
+        afterVisitMethod { MethodNode mn ->
+            mn.code?.visit(makeVisitor())
+        }
+    }
+
+    private CheckingVisitor makeVisitor() {
+        boolean strict = this.strict
+        new CheckingVisitor() {
+
+            private ClassNode safeType(Expression e) {
+                try { getType(e) } catch (ignored) { null }
+            }
+
+            @Override
+            void visitMethodCallExpression(MethodCallExpression call) {
+                super.visitMethodCallExpression(call)
+
+                MethodNode target = 
call.getNodeMetaData(StaticTypesMarker.DIRECT_METHOD_CALL_TARGET)
+                // Skip the DO-macro dispatcher — that's MonadicChecker's 
territory.
+                if (target?.declaringClass?.name == DISPATCHER) return
+
+                String name = call.methodAsString
+                if (!name) return
+
+                ClassNode receiverType = safeType(call.objectExpression)
+                CarrierInfo carrier = carrierFor(receiverType)
+                if (carrier == null) return
+
+                String role
+                if (name == carrier.bind) role = 'bind'
+                else if (name == carrier.map) role = 'map'
+                else return
+
+                List<Expression> args = (call.arguments instanceof 
ArgumentListExpression) ?
+                        ((ArgumentListExpression) call.arguments).expressions 
: []
+                if (args.isEmpty()) return
+                Expression fn = args[-1] // bind/map take a single function arg
+
+                ClassNode produced = functionReturnType(fn)
+                String msg = diagnose(carrier, role, produced, name, strict)
+                if (msg) addStaticTypeError(msg, call)
+            }
+
+            /**
+             * Best-effort static return-type for the function argument:
+             * STC metadata on a closure literal, generics on a method
+             * reference's resolved method, or null if neither applies.
+             */
+            private ClassNode functionReturnType(Expression fn) {
+                if (fn instanceof ClosureExpression) {
+                    ClassNode inferred = 
fn.getNodeMetaData(StaticTypesMarker.INFERRED_RETURN_TYPE)
+                    if (inferred != null && inferred != OBJECT_TYPE) return 
inferred
+                    // Fallback: read Closure<R> generics off the closure 
expression.
+                    def t = safeType(fn)
+                    def gts = t?.genericsTypes
+                    return (gts && gts.length > 0 && gts[0].type) ? 
gts[0].type : null
+                }
+                if (fn instanceof MethodPointerExpression) {
+                    MethodPointerExpression ref = (MethodPointerExpression) fn
+                    String rn = ref.methodName?.text
+                    ClassNode owner = (ref.expression instanceof 
ClassExpression) ?
+                            ((ClassExpression) ref.expression).type : 
safeType(ref.expression)
+                    if (rn && owner) {
+                        def ms = owner.redirect().getMethods(rn)
+                        if (ms) {
+                            // prefer the single-arg overload (bind/map shape)
+                            MethodNode m = ms.find { it.parameters?.size() == 
1 } ?: ms[0]
+                            return m?.returnType
+                        }
+                    }
+                    return null
+                }
+                null // ordinary value/variable — no reliable static handle
+            }
+        }
+    }
+
+    /** Pure static analysis: a diagnostic message, or null if the call is 
acceptable. */
+    private static String diagnose(CarrierInfo carrier, String role,
+                                   ClassNode produced, String methodName, 
boolean strict) {
+        boolean knownReturn = produced != null && produced != OBJECT_TYPE
+        CarrierInfo returnCarrier = knownReturn ? carrierFor(produced) : null
+
+        if (role == 'bind') {
+            if (!knownReturn) {
+                return strict ? "MonadicShapeChecker (strict): cannot 
statically verify that the " +
+                        "function passed to '${methodName}' on 
${carrier.canonical} returns another " +
+                        "${carrier.canonical}." : null
+            }
+            if (returnCarrier == null) {
+                return "MonadicShapeChecker: '${methodName}' on 
${carrier.canonical} expects its " +
+                        "function to return another ${carrier.canonical}; got 
${typeName(produced)}."
+            }
+            if (returnCarrier.canonical != carrier.canonical) {
+                return "MonadicShapeChecker: '${methodName}' on 
${carrier.canonical} expects its " +
+                        "function to return another ${carrier.canonical}; got 
${returnCarrier.canonical} " +
+                        "(crossing carrier types is almost certainly a bug)."
+            }
+            return null
+        }
+        // role == 'map'
+        if (!knownReturn) {
+            return strict ? "MonadicShapeChecker (strict): cannot statically 
verify that the " +
+                    "function passed to '${methodName}' on 
${carrier.canonical} returns a plain " +
+                    "value (and not another ${carrier.canonical})." : null
+        }
+        if (returnCarrier != null && returnCarrier.canonical == 
carrier.canonical) {
+            return "MonadicShapeChecker: '${methodName}' on 
${carrier.canonical} returns its " +
+                    "function's result wrapped, producing 
${carrier.canonical}<${carrier.canonical}<...>>; " +
+                    "did you mean '${carrier.bind}'?"
+        }
+        return null
+    }
+
+    private static String typeName(ClassNode cn) {
+        cn == null ? '<unknown>' : cn.toString(false)
+    }
+
+    // ---- carrier identification ----
+
+    /** Carrier info: canonical name (for same-carrier comparison) and method 
names. */
+    private static class CarrierInfo {
+        final String canonical
+        final String bind
+        final String map
+        CarrierInfo(String canonical, String bind, String map) {
+            this.canonical = canonical
+            this.bind = bind
+            this.map = map
+        }
+    }
+
+    /** Carrier info for the given type, or {@code null} if it is not a known 
carrier. */
+    private static CarrierInfo carrierFor(ClassNode cn) {
+        if (cn == null) return null
+        ClassNode bare = cn.redirect() ?: cn
+
+        // 1. Class-keyed allow-list (assignability)
+        for (e in MonadicCarrierRegistry.entries()) {
+            ClassNode t = make(e.carrier())
+            if (assignableTo(bare, t)) {
+                return new CarrierInfo(t.name, e.bind(), e.map())
+            }
+        }
+        // 2. Name-keyed allow-list (hierarchy walk by FQ name; no library 
dependency)
+        for (e in MonadicCarrierRegistry.namedEntries()) {
+            if (hasInHierarchyByName(bare, e.carrierName())) {
+                return new CarrierInfo(e.carrierName(), e.bind(), e.map())
+            }
+        }
+        // 3. @Monadic (matched by simple name, walking the hierarchy)
+        for (ClassNode c = bare; c != null && c.name != 'java.lang.Object'; c 
= c.superClass) {
+            AnnotationNode ann = c.annotations?.find { 
it.classNode?.nameWithoutPackage == 'Monadic' }
+            if (ann != null) {
+                String bind = readStringMember(ann, 'bind') ?: 'flatMap'
+                String map = readStringMember(ann, 'map') ?: 'map'
+                return new CarrierInfo(c.name, bind, map)
+            }
+        }
+        null
+    }
+
+    private static String readStringMember(AnnotationNode ann, String member) {
+        Expression e = ann.getMember(member)
+        if (e == null) return null
+        String s = e.text
+        s.isEmpty() ? null : s
+    }
+
+    private static boolean assignableTo(ClassNode cn, ClassNode t) {
+        cn == t || cn.isDerivedFrom(t) || cn.implementsInterface(t)
+    }
+
+    private static boolean hasInHierarchyByName(ClassNode cn, String fq) {
+        for (ClassNode c = cn; c != null && c.name != 'java.lang.Object'; c = 
c.superClass) {
+            if (c.name == fq) return true
+            ClassNode[] ifaces = c.interfaces
+            if (ifaces != null) {
+                for (i in ifaces) {
+                    if (i.name == fq) return true
+                    if (hasInHierarchyByName(i, fq)) return true
+                }
+            }
+        }
+        false
+    }
+}
diff --git 
a/subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/MonadicShapeCheckerTest.groovy
 
b/subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/MonadicShapeCheckerTest.groovy
new file mode 100644
index 0000000000..2b4e7bd1b1
--- /dev/null
+++ 
b/subprojects/groovy-typecheckers/src/test/groovy/groovy/typecheckers/MonadicShapeCheckerTest.groovy
@@ -0,0 +1,265 @@
+/*
+ *  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 groovy.typecheckers
+
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer
+import org.junit.jupiter.api.BeforeAll
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+import static groovy.test.GroovyAssert.shouldFail
+
+/**
+ * Tests for {@link MonadicShapeChecker}. Mirrors the {@code 
CombinerCheckerTest}
+ * harness: two shared shells, one lenient, one strict.
+ */
+final class MonadicShapeCheckerTest {
+
+    private static GroovyShell lenientShell
+    private static GroovyShell strictShell
+
+    @BeforeAll
+    static void setUp() {
+        lenientShell = makeShell(null)
+        strictShell = makeShell('strict')
+    }
+
+    private static GroovyShell makeShell(String mode) {
+        String ext = mode ? "groovy.typecheckers.MonadicShapeChecker(mode: 
'${mode}')" : 'groovy.typecheckers.MonadicShapeChecker'
+        new GroovyShell(new CompilerConfiguration().tap {
+            def customizer = new 
ASTTransformationCustomizer(groovy.transform.TypeChecked)
+            customizer.annotationParameters = [extensions: ext]
+            addCompilationCustomizers(customizer)
+        })
+    }
+
+    // ===== Optional =====
+
+    @Test
+    void optional_flatMap_returning_optional_passes() {
+        assertScript lenientShell, '''
+            assert Optional.of(2).flatMap { Integer x -> Optional.of(x + 1) 
}.get() == 3
+        '''
+    }
+
+    @Test
+    void optional_flatMap_returning_bare_value_fails() {
+        // JDK's flatMap signature requires Optional but STC can miss closures
+        // returning a bare value via Groovy SAM coercion.
+        def err = shouldFail lenientShell, '''
+            Optional.of(2).flatMap { Integer x -> x + 1 }
+        '''
+        assert err.message.contains("'flatMap' on java.util.Optional")
+        assert err.message.contains('expects its function to return another 
java.util.Optional')
+    }
+
+    @Test
+    void optional_flatMap_returning_stream_flags_cross_carrier() {
+        def err = shouldFail lenientShell, '''
+            import java.util.stream.Stream
+            Optional.of(2).flatMap { Integer x -> Stream.of(x) }
+        '''
+        assert err.message.contains('crossing carrier types is almost 
certainly a bug')
+    }
+
+    @Test
+    void optional_map_returning_optional_flags_nesting() {
+        def err = shouldFail lenientShell, '''
+            Optional.of(2).map { Integer x -> Optional.of(x + 1) }
+        '''
+        assert err.message.contains("'map' on java.util.Optional")
+        assert err.message.contains("did you mean 'flatMap'")
+    }
+
+    @Test
+    void optional_map_returning_plain_value_passes() {
+        assertScript lenientShell, '''
+            assert Optional.of(2).map { Integer x -> x + 1 }.get() == 3
+        '''
+    }
+
+    // ===== Stream =====
+
+    @Test
+    void stream_flatMap_returning_stream_passes() {
+        assertScript lenientShell, '''
+            import java.util.stream.Stream
+            assert Stream.of(1, 2).flatMap { Integer x -> Stream.of(x, x + 10) 
}.toList() == [1, 11, 2, 12]
+        '''
+    }
+
+    @Test
+    void stream_map_returning_stream_flags_nesting() {
+        def err = shouldFail lenientShell, '''
+            import java.util.stream.Stream
+            Stream.of(1, 2).map { Integer x -> Stream.of(x) }
+        '''
+        assert err.message.contains("'map' on java.util.stream.Stream")
+        assert err.message.contains("did you mean 'flatMap'")
+    }
+
+    // ===== CompletableFuture / CompletionStage =====
+
+    @Test
+    void cf_thenCompose_returning_cf_passes() {
+        assertScript lenientShell, '''
+            import java.util.concurrent.CompletableFuture
+            def r = CompletableFuture.completedFuture(2)
+                .thenCompose { Integer x -> 
CompletableFuture.completedFuture(x + 1) }
+            assert r.get() == 3
+        '''
+    }
+
+    @Test
+    void cf_thenCompose_returning_bare_value_fails() {
+        def err = shouldFail lenientShell, '''
+            import java.util.concurrent.CompletableFuture
+            CompletableFuture.completedFuture(2).thenCompose { Integer x -> x 
+ 1 }
+        '''
+        assert err.message.contains("'thenCompose' on 
java.util.concurrent.CompletionStage")
+    }
+
+    @Test
+    void cf_thenApply_returning_cf_flags_nesting() {
+        def err = shouldFail lenientShell, '''
+            import java.util.concurrent.CompletableFuture
+            CompletableFuture.completedFuture(2).thenApply { Integer x -> 
CompletableFuture.completedFuture(x) }
+        '''
+        assert err.message.contains("'thenApply' on 
java.util.concurrent.CompletionStage")
+        assert err.message.contains("did you mean 'thenCompose'")
+    }
+
+    // ===== @Monadic user-declared carriers =====
+
+    @Test
+    void monadic_annotated_carrier_bind_returning_carrier_passes() {
+        assertScript lenientShell, '''
+            import groovy.transform.Monadic
+            @Monadic
+            class Box<T> {
+                final T v
+                Box(T v) { this.v = v }
+                Box flatMap(Closure c) { (Box) c.call(v) }
+                Box map(Closure c) { new Box(c.call(v)) }
+            }
+
+            assert new Box(2).flatMap { Integer x -> new Box(x + 1) }.v == 3
+        '''
+    }
+
+    @Test
+    void monadic_annotated_carrier_map_returning_carrier_flags_nesting() {
+        def err = shouldFail lenientShell, '''
+            import groovy.transform.Monadic
+            @Monadic
+            class Box<T> {
+                final T v
+                Box(T v) { this.v = v }
+                Box flatMap(Closure c) { (Box) c.call(v) }
+                Box map(Closure c) { new Box(c.call(v)) }
+            }
+
+            new Box(2).map { Integer x -> new Box(x + 1) }
+        '''
+        assert err.message.contains("did you mean 'flatMap'")
+    }
+
+    @Test
+    void monadic_with_custom_bind_map_names() {
+        // @Monadic(bind='chain', map='transform') — verify the configured 
names are honoured.
+        def err = shouldFail lenientShell, '''
+            import groovy.transform.Monadic
+            import java.util.function.Function
+
+            @Monadic(bind = 'chain', map = 'transform')
+            class Res {
+                final Object v
+                Res(Object v) { this.v = v }
+                Res chain(Function f) { (Res) f.apply(v) }
+                Res transform(Function f) { new Res(f.apply(v)) }
+            }
+
+            new Res(2).transform { x -> new Res(x) }
+        '''
+        assert err.message.contains("did you mean 'chain'")
+    }
+
+    // ===== modes =====
+
+    @Test
+    void lenient_does_not_flag_when_return_is_unknown() {
+        // Closure passed as a variable — no static handle on its return type.
+        assertScript lenientShell, '''
+            Closure c = { Integer x -> Optional.of(x + 1) }
+            Optional.of(2).flatMap(c)
+        '''
+    }
+
+    @Test
+    void strict_flags_when_return_is_unknown() {
+        def err = shouldFail strictShell, '''
+            Closure c = { Integer x -> Optional.of(x + 1) }
+            Optional.of(2).flatMap(c)
+        '''
+        assert err.message.contains('cannot statically verify')
+    }
+
+    // ===== passthrough / non-engagement =====
+
+    @Test
+    void non_carrier_receiver_ignored() {
+        // Compile-only: the assertion is the checker does NOT raise a static 
error
+        // on a non-registered type, even though the shapes here (flatMap 
returning
+        // bare, map returning the receiver) would be flagged on a carrier.
+        lenientShell.parse '''
+            class Holder<T> {
+                final T v
+                Holder(T v) { this.v = v }
+                Holder flatMap(Closure c) { (Holder) c.call(v) }
+                Holder map(Closure c) { new Holder(c.call(v)) }
+            }
+            void check() {
+                new Holder(2).flatMap { Integer x -> x + 1 }
+                new Holder(2).map { Integer x -> new Holder(x) }
+            }
+        '''
+    }
+
+    @Test
+    void unrelated_map_call_ignored() {
+        // List<E>.collect/Map<K,V>.collect are 'map'-like but not in the 
carrier
+        // registry; the checker must not interfere.
+        assertScript lenientShell, '''
+            assert [1, 2, 3].collect { it + 1 } == [2, 3, 4]
+        '''
+    }
+
+    @Test
+    void comprehensions_dispatcher_calls_are_left_to_monadic_checker() {
+        // A direct Comprehensions.bind/map call is MonadicChecker's job; this
+        // checker must not double-flag a (correct) shape there.
+        assertScript lenientShell, '''
+            import org.apache.groovy.runtime.Comprehensions
+            assert ((Optional<Integer>) Comprehensions.bind(Optional.of(2)) { 
Integer x ->
+                Comprehensions.bind(Optional.of(3)) { Integer y -> 
Optional.of(x + y) }
+            }).get() == 5
+        '''
+    }
+}

Reply via email to