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 6b109a62a7 GROOVY-12008: Sealed types: graduate from incubating status
6b109a62a7 is described below

commit 6b109a62a7987c2a9ab7a6c1456693f0a3f3bf58
Author: Paul King <[email protected]>
AuthorDate: Thu May 14 22:58:56 2026 +1000

    GROOVY-12008: Sealed types: graduate from incubating status
---
 src/main/java/groovy/lang/Delegate.java            |   2 +-
 src/main/java/groovy/transform/NonSealed.java      |   2 -
 src/main/java/groovy/transform/Sealed.java         |   2 -
 src/main/java/groovy/transform/SealedOptions.java  |   3 -
 .../java/org/codehaus/groovy/ast/ClassNode.java    |   3 -
 .../groovy/tools/javac/JavaStubGenerator.java      |  77 ++++++++-
 .../groovy/transform/SealedASTTransformation.java  |  98 ++++++++++-
 src/spec/doc/_sealed.adoc                          |   4 +-
 src/test/groovy/bugs/Groovy11292.groovy            | 123 --------------
 .../transform/SealedJointCompilationTest.groovy    | 183 +++++++++++++++++++++
 .../groovy/transform/SealedTransformTest.groovy    | 113 +++++++++++++
 11 files changed, 469 insertions(+), 141 deletions(-)

diff --git a/src/main/java/groovy/lang/Delegate.java 
b/src/main/java/groovy/lang/Delegate.java
index 648189560b..35f457c51f 100644
--- a/src/main/java/groovy/lang/Delegate.java
+++ b/src/main/java/groovy/lang/Delegate.java
@@ -160,7 +160,7 @@ public @interface Delegate {
 
     /**
      * Whether to carry over annotations from the methods of the delegate
-     * to your delegating method. Currently Closure annotation members are
+     * to your delegating method. Currently, Closure annotation members are
      * not supported.
      *
      * @return true if generated delegate methods should keep method 
annotations
diff --git a/src/main/java/groovy/transform/NonSealed.java 
b/src/main/java/groovy/transform/NonSealed.java
index b6e9169f55..f87af19c1d 100644
--- a/src/main/java/groovy/transform/NonSealed.java
+++ b/src/main/java/groovy/transform/NonSealed.java
@@ -18,7 +18,6 @@
  */
 package groovy.transform;
 
-import org.apache.groovy.lang.annotation.Incubating;
 import org.codehaus.groovy.transform.GroovyASTTransformationClass;
 
 import java.lang.annotation.Documented;
@@ -33,7 +32,6 @@ import java.lang.annotation.Target;
  * @since 4.0.0
  */
 @Documented
-@Incubating
 @Retention(RetentionPolicy.SOURCE)
 @Target(ElementType.TYPE)
 
@GroovyASTTransformationClass("org.codehaus.groovy.transform.NonSealedASTTransformation")
diff --git a/src/main/java/groovy/transform/Sealed.java 
b/src/main/java/groovy/transform/Sealed.java
index 7e75f83c62..9e78cdfec9 100644
--- a/src/main/java/groovy/transform/Sealed.java
+++ b/src/main/java/groovy/transform/Sealed.java
@@ -18,7 +18,6 @@
  */
 package groovy.transform;
 
-import org.apache.groovy.lang.annotation.Incubating;
 import org.codehaus.groovy.transform.GroovyASTTransformationClass;
 
 import java.lang.annotation.Documented;
@@ -33,7 +32,6 @@ import java.lang.annotation.Target;
  * @since 4.0.0
  */
 @Documented
-@Incubating
 @Retention(RetentionPolicy.RUNTIME)
 @Target(ElementType.TYPE)
 @GroovyASTTransformationClass({
diff --git a/src/main/java/groovy/transform/SealedOptions.java 
b/src/main/java/groovy/transform/SealedOptions.java
index ef03e61365..8107d4f542 100644
--- a/src/main/java/groovy/transform/SealedOptions.java
+++ b/src/main/java/groovy/transform/SealedOptions.java
@@ -18,8 +18,6 @@
  */
 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;
@@ -32,7 +30,6 @@ import java.lang.annotation.Target;
  * @since 4.0.0
  */
 @Documented
-@Incubating
 @Retention(RetentionPolicy.SOURCE)
 @Target(ElementType.TYPE)
 public @interface SealedOptions {
diff --git a/src/main/java/org/codehaus/groovy/ast/ClassNode.java 
b/src/main/java/org/codehaus/groovy/ast/ClassNode.java
index bd48a1cd08..5ed4541825 100644
--- a/src/main/java/org/codehaus/groovy/ast/ClassNode.java
+++ b/src/main/java/org/codehaus/groovy/ast/ClassNode.java
@@ -629,7 +629,6 @@ public class ClassNode extends AnnotatedNode {
     /**
      * @return permitted subclasses of sealed type, may initially be empty in 
early compiler phases
      */
-    @Incubating
     public List<ClassNode> getPermittedSubclasses() {
         if (redirect != null)
             return redirect.getPermittedSubclasses();
@@ -637,7 +636,6 @@ public class ClassNode extends AnnotatedNode {
         return permittedSubclasses;
     }
 
-    @Incubating
     public void setPermittedSubclasses(final List<ClassNode> 
permittedSubclasses) {
         if (redirect != null) {
             redirect.setPermittedSubclasses(permittedSubclasses);
@@ -1916,7 +1914,6 @@ faces:  if (method == null && asBoolean(getInterfaces())) 
{ // GROOVY-11323
      * @return {@code true} for native and emulated (annotation based) sealed 
classes
      * @since 4.0.0
      */
-    @Incubating
     public boolean isSealed() {
         if (redirect != null) return redirect.isSealed();
         return !getAnnotations(ClassHelper.SEALED_TYPE).isEmpty() || 
!getPermittedSubclasses().isEmpty();
diff --git 
a/src/main/java/org/codehaus/groovy/tools/javac/JavaStubGenerator.java 
b/src/main/java/org/codehaus/groovy/tools/javac/JavaStubGenerator.java
index 2e930e5367..17da992767 100644
--- a/src/main/java/org/codehaus/groovy/tools/javac/JavaStubGenerator.java
+++ b/src/main/java/org/codehaus/groovy/tools/javac/JavaStubGenerator.java
@@ -18,8 +18,11 @@
  */
 package org.codehaus.groovy.tools.javac;
 
+import groovy.transform.NonSealed;
 import groovy.transform.PackageScope;
 import groovy.transform.PackageScopeTarget;
+import groovy.transform.Sealed;
+import groovy.transform.SealedOptions;
 import org.apache.groovy.ast.tools.ExpressionUtils;
 import org.apache.groovy.io.StringBuilderWriter;
 import org.codehaus.groovy.ast.AnnotatedNode;
@@ -113,6 +116,9 @@ import static 
org.codehaus.groovy.ast.tools.GenericsUtils.correctToGenericsSpecR
 import static org.codehaus.groovy.ast.tools.GenericsUtils.createGenericsSpec;
 import static 
org.codehaus.groovy.ast.tools.WideningCategories.isFloatingCategory;
 import static org.codehaus.groovy.ast.tools.WideningCategories.isLongCategory;
+import static 
org.codehaus.groovy.transform.SealedASTTransformation.getEffectivePermittedSubclasses;
+import static 
org.codehaus.groovy.transform.SealedASTTransformation.sealedSkipAnnotationFromSource;
+import static 
org.codehaus.groovy.transform.SealedASTTransformation.wouldBeNativeSealed;
 
 /**
  * Generates Java stub source for Groovy classes during joint compilation.
@@ -125,6 +131,9 @@ public class JavaStubGenerator {
     private final List<ConstructorNode> constructors = new ArrayList<>();
     private final Map<String, MethodNode> propertyMethods = new 
LinkedHashMap<>();
     private final static ClassNode PACKAGE_SCOPE_TYPE = 
makeCached(PackageScope.class);
+    private final static ClassNode SEALED_TYPE = makeCached(Sealed.class);
+    private final static ClassNode NON_SEALED_TYPE = 
makeCached(NonSealed.class);
+    private final static ClassNode SEALED_OPTIONS_TYPE = 
makeCached(SealedOptions.class);
 
     private ModuleNode currentModule;
 
@@ -371,6 +380,21 @@ public class JavaStubGenerator {
             // as plain classes (the existing behaviour).
             boolean isRecordStub = !isEnum && !isInterface && 
!isAnnotationDefinition
                     && isNativeRecordStub(classNode);
+            // Emit native sealed declarations (sealed keyword + permits 
clause)
+            // when the class will receive native sealed bytecode. EMULATE mode
+            // is intentionally skipped: GEP-13 specifies emulated sealed types
+            // are invisible to the Java compiler.
+            boolean isSealedStub = !isAnnotationDefinition && !isEnum
+                    && isNativeSealedStub(classNode);
+            // Java requires final / sealed / non-sealed on every permitted
+            // subtype of a sealed type. Groovy allows implicit non-sealed, so
+            // emit the non-sealed keyword in the stub when the parent is
+            // sealed (or a sealed interface is implemented) and this class
+            // is neither final nor sealed.
+            boolean isNonSealedStub = !isAnnotationDefinition && !isEnum && 
!isInterface && !isRecordStub
+                    && !isSealedStub
+                    && (classNode.getModifiers() & Opcodes.ACC_FINAL) == 0
+                    && hasSealedSupertype(classNode);
             printAnnotations(out, classNode);
 
             int flags = classNode.getModifiers();
@@ -382,6 +406,9 @@ public class JavaStubGenerator {
             if (isRecordStub) flags &= ~Opcodes.ACC_FINAL;
             printModifiers(out, flags);
 
+            if (isSealedStub) out.print("sealed ");
+            else if (isNonSealedStub) out.print("non-sealed ");
+
             if (isInterface) {
                 if (isAnnotationDefinition) {
                     out.print("@");
@@ -427,6 +454,17 @@ public class JavaStubGenerator {
                 out.print("    ");
                 printType(out, interfaces[interfaces.length - 1]);
             }
+            if (isSealedStub) {
+                List<ClassNode> permitted = 
getEffectivePermittedSubclasses(classNode);
+                if (!permitted.isEmpty()) {
+                    out.println();
+                    out.print("  permits ");
+                    for (int i = 0, n = permitted.size(); i < n; i += 1) {
+                        if (i > 0) out.print(", ");
+                        printType(out, permitted.get(i));
+                    }
+                }
+            }
             out.println(" {");
 
             printFields(out, classNode, isInterface, isRecordStub);
@@ -452,6 +490,31 @@ public class JavaStubGenerator {
         return RecordTypeASTTransformation.wouldBeNativeRecord(classNode, 
targetBytecode);
     }
 
+    private boolean isNativeSealedStub(final ClassNode classNode) {
+        if (currentModule == null || currentModule.getContext() == null) 
return false;
+        String targetBytecode = 
currentModule.getContext().getConfiguration().getTargetBytecode();
+        return wouldBeNativeSealed(classNode, targetBytecode);
+    }
+
+    private boolean hasSealedSupertype(final ClassNode classNode) {
+        if (isNativeSupertypeSealed(classNode.getSuperClass())) return true;
+        ClassNode[] ifaces = classNode.getInterfaces();
+        if (ifaces != null) {
+            for (ClassNode iface : ifaces) {
+                if (isNativeSupertypeSealed(iface)) return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean isNativeSupertypeSealed(final ClassNode supertype) {
+        if (supertype == null) return false;
+        // Already-compiled (e.g. JDK17+ sealed Java class loaded from 
bytecode).
+        if (supertype.isSealed() && !supertype.isPrimaryClassNode()) return 
true;
+        // Same compilation unit, will be emitted as native sealed.
+        return isNativeSealedStub(supertype);
+    }
+
     private void printRecordHeader(final PrintWriter out, final ClassNode 
classNode) {
         List<PropertyNode> components = getInstanceProperties(classNode);
         out.print('(');
@@ -1037,9 +1100,19 @@ public class JavaStubGenerator {
     }
 
     private void printAnnotations(final PrintWriter out, final AnnotatedNode 
annotated) {
+        // @NonSealed and @SealedOptions have SOURCE retention, so they never 
appear in
+        // the bytecode surface a Java consumer sees — suppress them from the 
stub.
+        // @Sealed has RUNTIME retention; suppress it only when the source 
emits the
+        // sealed keyword and @SealedOptions(alwaysAnnotate=false) (parallels 
the
+        // bytecode-side filter in AsmClassGenerator).
+        boolean skipSealedAnno = annotated instanceof ClassNode cn
+                && isNativeSealedStub(cn) && 
sealedSkipAnnotationFromSource(cn);
         for (AnnotationNode annotation : annotated.getAnnotations()) {
-            if (!annotation.getClassNode().equals(PACKAGE_SCOPE_TYPE))
-                printAnnotation(out, annotation);
+            ClassNode type = annotation.getClassNode();
+            if (type.equals(PACKAGE_SCOPE_TYPE)) continue;
+            if (type.equals(NON_SEALED_TYPE) || 
type.equals(SEALED_OPTIONS_TYPE)) continue;
+            if (skipSealedAnno && type.equals(SEALED_TYPE)) continue;
+            printAnnotation(out, annotation);
         }
     }
 
diff --git 
a/src/main/java/org/codehaus/groovy/transform/SealedASTTransformation.java 
b/src/main/java/org/codehaus/groovy/transform/SealedASTTransformation.java
index 7a71b17334..c2c10b8444 100644
--- a/src/main/java/org/codehaus/groovy/transform/SealedASTTransformation.java
+++ b/src/main/java/org/codehaus/groovy/transform/SealedASTTransformation.java
@@ -21,18 +21,21 @@ package org.codehaus.groovy.transform;
 import groovy.transform.Sealed;
 import groovy.transform.SealedMode;
 import groovy.transform.SealedOptions;
-import org.apache.groovy.lang.annotation.Incubating;
 import org.codehaus.groovy.ast.ASTNode;
 import org.codehaus.groovy.ast.AnnotatedNode;
 import org.codehaus.groovy.ast.AnnotationNode;
 import org.codehaus.groovy.ast.ClassNode;
 import org.codehaus.groovy.ast.expr.ClassExpression;
+import org.codehaus.groovy.ast.expr.ConstantExpression;
 import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.ListExpression;
 import org.codehaus.groovy.ast.expr.PropertyExpression;
 import org.codehaus.groovy.control.CompilePhase;
 import org.codehaus.groovy.control.CompilerConfiguration;
 import org.codehaus.groovy.control.SourceUnit;
 
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 
 import static org.codehaus.groovy.ast.ClassHelper.make;
@@ -109,7 +112,6 @@ public class SealedASTTransformation extends 
AbstractASTTransformation {
      *
      * @return true for a native sealed class
      */
-    @Incubating
     public static boolean sealedNative(AnnotatedNode node) {
         return node.getNodeMetaData(SealedMode.class) == SealedMode.NATIVE;
     }
@@ -121,11 +123,101 @@ public class SealedASTTransformation extends 
AbstractASTTransformation {
      *
      * @return true if a {@code Sealed} annotation is not required for this 
node
      */
-    @Incubating
     public static boolean sealedSkipAnnotation(AnnotatedNode node) {
         return 
Boolean.FALSE.equals(node.getNodeMetaData(SEALED_ALWAYS_ANNOTATE_KEY));
     }
 
+    /**
+     * Reports whether {@code cNode} would receive native sealed bytecode for 
the
+     * given {@code targetBytecode} level. Independent of compile-phase 
ordering,
+     * so callers earlier than {@code SEMANTIC_ANALYSIS} (e.g. the 
joint-compile
+     * stub generator) can use it.
+     *
+     * @param cNode the class being checked
+     * @param targetBytecode the target bytecode level
+     *                       (see {@link 
CompilerConfiguration#getTargetBytecode()})
+     */
+    public static boolean wouldBeNativeSealed(final ClassNode cNode, final 
String targetBytecode) {
+        if (cNode == null || !cNode.isSealed()) return false;
+        if (targetBytecode == null || targetBytecode.trim().isEmpty()) return 
false;
+        if (!isAtLeast(targetBytecode, CompilerConfiguration.JDK17)) return 
false;
+        List<AnnotationNode> opts = cNode.getAnnotations(SEALED_OPTIONS_TYPE);
+        AnnotationNode options = opts.isEmpty() ? null : opts.get(0);
+        return getMode(options, "mode") != SealedMode.EMULATE;
+    }
+
+    /**
+     * Returns the explicitly-declared permitted-subclass list for {@code 
cNode},
+     * either from the populated {@code permittedSubclasses} field (after the
+     * transform has run) or from the {@code @Sealed} annotation's
+     * {@code permittedSubclasses} member. An empty list means no explicit list
+     * is declared in source — inference happens later via
+     * {@code SealedCompletionASTTransformation}. Independent of compile-phase
+     * ordering, so callers earlier than {@code SEMANTIC_ANALYSIS} can use it.
+     */
+    public static List<ClassNode> getDeclaredPermittedSubclasses(final 
ClassNode cNode) {
+        if (cNode == null) return Collections.emptyList();
+        List<ClassNode> populated = cNode.getPermittedSubclasses();
+        if (!populated.isEmpty()) return populated;
+        List<ClassNode> result = new ArrayList<>();
+        for (AnnotationNode anno : cNode.getAnnotations(SEALED_TYPE)) {
+            Expression m = anno.getMember("permittedSubclasses");
+            if (m instanceof ListExpression le) {
+                for (Expression e : le.getExpressions()) {
+                    if (e instanceof ClassExpression ce) 
result.add(ce.getType());
+                }
+            } else if (m instanceof ClassExpression ce) {
+                result.add(ce.getType());
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Returns the effective permitted-subclass list for {@code cNode}: the
+     * explicitly-declared list when present, otherwise the inferred list
+     * obtained by scanning the enclosing compilation unit for direct
+     * subtypes (mirroring {@code SealedCompletionASTTransformation}). Phase
+     * independent, so callers earlier than {@code CANONICALIZATION} can use 
it.
+     */
+    public static List<ClassNode> getEffectivePermittedSubclasses(final 
ClassNode cNode) {
+        List<ClassNode> declared = getDeclaredPermittedSubclasses(cNode);
+        if (!declared.isEmpty()) return declared;
+        if (cNode == null || cNode.getModule() == null) return declared;
+        if (cNode.getAnnotations(SEALED_TYPE).isEmpty()) return declared;
+        List<ClassNode> inferred = new ArrayList<>();
+        for (ClassNode possibleSubclass : cNode.getModule().getClasses()) {
+            if (possibleSubclass.equals(cNode)) continue;
+            if (cNode.equals(possibleSubclass.getSuperClass())) {
+                inferred.add(possibleSubclass);
+                continue;
+            }
+            for (ClassNode iface : possibleSubclass.getInterfaces()) {
+                if (cNode.equals(iface)) {
+                    inferred.add(possibleSubclass);
+                    break;
+                }
+            }
+        }
+        return inferred;
+    }
+
+    /**
+     * Reports whether the source for {@code cNode} uses
+     * {@code @SealedOptions(alwaysAnnotate=false)} — independent of 
compile-phase
+     * ordering, so callers earlier than {@code SEMANTIC_ANALYSIS} can use it.
+     */
+    public static boolean sealedSkipAnnotationFromSource(final ClassNode 
cNode) {
+        if (cNode == null) return false;
+        for (AnnotationNode opts : cNode.getAnnotations(SEALED_OPTIONS_TYPE)) {
+            Expression m = opts.getMember("alwaysAnnotate");
+            if (m instanceof ConstantExpression ce && 
Boolean.FALSE.equals(ce.getValue())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     private static SealedMode getMode(AnnotationNode node, String name) {
         if (node != null) {
             final Expression member = node.getMember(name);
diff --git a/src/spec/doc/_sealed.adoc b/src/spec/doc/_sealed.adoc
index a64766483d..de707b2736 100644
--- a/src/spec/doc/_sealed.adoc
+++ b/src/spec/doc/_sealed.adoc
@@ -19,7 +19,7 @@
 
 //////////////////////////////////////////
 
-= Sealed hierarchies (incubating)
+= Sealed hierarchies
 
 Sealed classes, interfaces and traits restrict which subclasses can 
extend/implement them.
 Prior to sealed classes, class hierarchy designers had two main options:
@@ -143,7 +143,7 @@ EMULATE::
 Indicates the class is sealed using the `@Sealed` annotation.
 This mechanism works with the Groovy compiler for JDK8+ but is not recognised 
by the Java compiler.
 AUTO::
-Produces a native record for JDK17+ and emulates the record otherwise.
+Produces a native sealed class for JDK17+ and emulates the sealed nature 
otherwise.
 
 Whether you use the `sealed` keyword or the `@Sealed` annotation
 is independent of the mode.
diff --git a/src/test/groovy/bugs/Groovy11292.groovy 
b/src/test/groovy/bugs/Groovy11292.groovy
deleted file mode 100644
index 3b5c3dfd09..0000000000
--- a/src/test/groovy/bugs/Groovy11292.groovy
+++ /dev/null
@@ -1,123 +0,0 @@
-/*
- *  Licensed to the Apache Software Foundation (ASF) under one
- *  or more contributor license agreements.  See the NOTICE file
- *  distributed with this work for additional information
- *  regarding copyright ownership.  The ASF licenses this file
- *  to you under the Apache License, Version 2.0 (the
- *  "License"); you may not use this file except in compliance
- *  with the License.  You may obtain a copy of the License at
- *
- *    http://www.apache.org/licenses/LICENSE-2.0
- *
- *  Unless required by applicable law or agreed to in writing,
- *  software distributed under the License is distributed on an
- *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- *  KIND, either express or implied.  See the License for the
- *  specific language governing permissions and limitations
- *  under the License.
- */
-package bugs
-
-import org.codehaus.groovy.control.CompilerConfiguration
-import org.codehaus.groovy.tools.javac.JavaAwareCompilationUnit
-import org.junit.jupiter.api.Test
-
-import static groovy.test.GroovyAssert.assertScript
-
-final class Groovy11292 {
-
-    @Test
-    void testClassWithNonSealedParent1() {
-        assertScript '''import java.lang.ref.SoftReference // non-sealed type
-
-            class TestReference<T> extends SoftReference<T> {
-                TestReference(T referent) {
-                    super(referent)
-                }
-            }
-
-            assert new TestReference(null)
-        '''
-    }
-
-    @Test
-    void testClassWithNonSealedParent2() {
-        def config = new CompilerConfiguration(
-            targetDirectory: File.createTempDir(),
-            jointCompilationOptions: [memStub: true]
-        )
-
-        def parentDir = File.createTempDir()
-        try {
-            def a = new File(parentDir, 'A.java')
-            a.write '''
-                public sealed class A permits B {}
-            '''
-            def b = new File(parentDir, 'B.java')
-            b.write '''
-                public non-sealed class B extends A {}
-            '''
-            def c = new File(parentDir, 'C.groovy')
-            c.write '''
-                class C extends B {}
-            '''
-            def d = new File(parentDir, 'D.groovy')
-            d.write '''
-                class D extends C {}
-            '''
-            def e = new File(parentDir, 'E.groovy')
-            e.write '''
-                class E extends B {}
-            '''
-            def f = new File(parentDir, 'F.groovy')
-            f.write '''
-                class F extends E {}
-            '''
-
-            def loader = new GroovyClassLoader(this.class.classLoader)
-            def cu = new JavaAwareCompilationUnit(config, loader)
-            cu.addSources(a, b, c, d, e, f)
-            cu.compile()
-        } finally {
-            config.targetDirectory.deleteDir()
-            parentDir.deleteDir()
-        }
-    }
-
-    // GROOVY-11768
-    @Test
-    void testClassWithNonSealedParent3() {
-        def config = new CompilerConfiguration(
-            targetDirectory: File.createTempDir(),
-            jointCompilationOptions: [memStub: true]
-        )
-
-        def parentDir = File.createTempDir()
-        try {
-            def a = new File(parentDir, 'A.java')
-            a.write '''
-                public abstract sealed class A permits B {}
-            '''
-            def b = new File(parentDir, 'B.java')
-            b.write '''
-                public abstract non-sealed class B extends A {}
-            '''
-            def c = new File(parentDir, 'C.java')
-            c.write '''
-                public class C extends B {}
-            '''
-            def d = new File(parentDir, 'D.groovy')
-            d.write '''
-                class D extends C {}
-            '''
-
-            def loader = new GroovyClassLoader(this.class.classLoader)
-            def cu = new JavaAwareCompilationUnit(config, loader)
-            cu.addSources(a, b, c, d)
-            cu.compile()
-        } finally {
-            config.targetDirectory.deleteDir()
-            parentDir.deleteDir()
-        }
-    }
-}
diff --git 
a/src/test/groovy/org/codehaus/groovy/transform/SealedJointCompilationTest.groovy
 
b/src/test/groovy/org/codehaus/groovy/transform/SealedJointCompilationTest.groovy
new file mode 100644
index 0000000000..9f1a23b97b
--- /dev/null
+++ 
b/src/test/groovy/org/codehaus/groovy/transform/SealedJointCompilationTest.groovy
@@ -0,0 +1,183 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.codehaus.groovy.transform
+
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.tools.javac.JavaAwareCompilationUnit
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+import static groovy.test.GroovyAssert.isAtLeastJdk
+import static groovy.test.GroovyAssert.shouldFail
+import static org.junit.jupiter.api.Assumptions.assumeTrue
+
+/**
+ * Joint-compilation and decompiled-types matrix for sealed types per
+ * GEP-13 §"Joint compilation and decompiled types":
+ *
+ * <ul>
+ * <li>a Groovy class extending a Java sealed class is checked against the
+ *     Java type's {@code permits} set;</li>
+ * <li>a Java class extending a Groovy sealed class likewise honours the
+ *     Groovy {@code permits} set;</li>
+ * <li>for a class loaded from bytecode (without source available),
+ *     non-sealed status is computed as: <em>the parent is sealed</em> AND
+ *     <em>this type is neither final nor sealed</em>.</li>
+ * </ul>
+ *
+ * The reverse-direction tests rely on native sealed bytecode, which
+ * requires JDK 17+.
+ */
+final class SealedJointCompilationTest {
+
+    private static void compileSources(Map<String, String> sources) {
+        def config = new CompilerConfiguration(
+            targetDirectory: File.createTempDir(),
+            jointCompilationOptions: [memStub: true]
+        )
+        def parentDir = File.createTempDir()
+        try {
+            def files = sources.collect { name, content ->
+                def f = new File(parentDir, name)
+                f.write(content)
+                f
+            }
+            def loader = new 
GroovyClassLoader(SealedJointCompilationTest.classLoader)
+            def cu = new JavaAwareCompilationUnit(config, loader)
+            cu.addSources(files as File[])
+            cu.compile()
+        } finally {
+            config.targetDirectory.deleteDir()
+            parentDir.deleteDir()
+        }
+    }
+
+    // GROOVY-11292
+    @Test
+    void testExtendingDecompiledNonSealedJdkClass() {
+        assertScript '''import java.lang.ref.SoftReference // non-sealed type
+
+            class TestReference<T> extends SoftReference<T> {
+                TestReference(T referent) {
+                    super(referent)
+                }
+            }
+
+            assert new TestReference(null)
+        '''
+    }
+
+    // GROOVY-11292
+    @Test
+    void testJavaSealedJavaNonSealedGroovyDescendants() {
+        compileSources([
+            'A.java'  : 'public sealed class A permits B {}',
+            'B.java'  : 'public non-sealed class B extends A {}',
+            'C.groovy': 'class C extends B {}',
+            'D.groovy': 'class D extends C {}',
+            'E.groovy': 'class E extends B {}',
+            'F.groovy': 'class F extends E {}'
+        ])
+    }
+
+    // GROOVY-11768
+    @Test
+    void testJavaSealedAbstractNonSealedJavaIntermediateGroovyDescendant() {
+        compileSources([
+            'A.java'  : 'public abstract sealed class A permits B {}',
+            'B.java'  : 'public abstract non-sealed class B extends A {}',
+            'C.java'  : 'public class C extends B {}',
+            'D.groovy': 'class D extends C {}'
+        ])
+    }
+
+    @Test
+    void testJavaSealedDirectGroovyPermitted() {
+        compileSources([
+            'A.java'  : 'public sealed class A permits B {}',
+            'B.groovy': 'final class B extends A {}'
+        ])
+    }
+
+    @Test
+    void testJavaSealedGroovyNotPermitted() {
+        shouldFail {
+            compileSources([
+                'A.java'  : 'public sealed class A permits B {}',
+                'B.java'  : 'public final class B extends A {}',
+                'C.groovy': 'class C extends A {}'
+            ])
+        }
+    }
+
+    @Test
+    void testGroovySealedJavaPermitted() {
+        assumeTrue(isAtLeastJdk('17.0'))
+        compileSources([
+            'A.groovy': 'sealed class A permits B {}',
+            'B.java'  : 'public final class B extends A {}'
+        ])
+    }
+
+    // Groovy sealed -> Groovy implicit-non-sealed intermediate -> Java 
descendant.
+    // The stub for the Groovy intermediate must declare non-sealed for javac 
to
+    // accept it as a permitted subtype of the sealed parent.
+    @Test
+    void testGroovySealedImplicitNonSealedGroovyIntermediateJavaDescendant() {
+        assumeTrue(isAtLeastJdk('17.0'))
+        compileSources([
+            'A.groovy': 'sealed class A permits B {}',
+            'B.groovy': 'class B extends A {}',
+            'C.java'  : 'public class C extends B {}'
+        ])
+    }
+
+    // Inferred permits (sealed in source, permitted subtypes inferred from the
+    // same compilation unit). Stub-gen must surface the inferred list so javac
+    // sees the sealed contract.
+    @Test
+    void testGroovySealedInferredPermitsJavaConsumer() {
+        assumeTrue(isAtLeastJdk('17.0'))
+        // Positive: Java permitted subtype that IS the inferred one.
+        compileSources([
+            'AB.groovy': '@groovy.transform.Sealed class A {}\nfinal class B 
extends A {}',
+            // No-op file referring to A's permitted subtype B just to 
exercise the surface.
+            'Use.java' : 'public class Use { public B b() { return null; } }'
+        ])
+        // Negative: Java tries to extend A but isn't in the inferred permits.
+        shouldFail {
+            compileSources([
+                'AB.groovy': '@groovy.transform.Sealed class A {}\nfinal class 
B extends A {}',
+                'C.java'   : 'public final class C extends A {}'
+            ])
+        }
+    }
+
+    @Test
+    void testGroovySealedJavaNotPermitted() {
+        assumeTrue(isAtLeastJdk('17.0'))
+        shouldFail {
+            compileSources([
+                'A.groovy': 'sealed class A permits B {}',
+                'B.java'  : 'public final class B extends A {}',
+                'C.java'  : 'public final class C extends A {}'
+            ])
+        }
+    }
+}
diff --git 
a/src/test/groovy/org/codehaus/groovy/transform/SealedTransformTest.groovy 
b/src/test/groovy/org/codehaus/groovy/transform/SealedTransformTest.groovy
index 95bca0d758..85f38f7a96 100644
--- a/src/test/groovy/org/codehaus/groovy/transform/SealedTransformTest.groovy
+++ b/src/test/groovy/org/codehaus/groovy/transform/SealedTransformTest.groovy
@@ -263,4 +263,117 @@ class SealedTransformTest {
             new Foo()
         '''
     }
+
+    // GEP-13: 'sealed' and 'permits' are restricted identifiers, not reserved
+    // keywords, so they remain usable as identifiers outside type-declaration
+    // contexts.
+    @Test
+    void testRestrictedIdentifiers() {
+        assertScript '''
+            def sealed = 42
+            def permits = [1, 2, 3]
+            assert sealed == 42
+            assert permits == [1, 2, 3]
+        '''
+        assertScript '''
+            int compute(int sealed, List permits) { sealed + permits.size() }
+            assert compute(10, [1, 2]) == 12
+        '''
+    }
+
+    // GEP-13: a permitted subtype with no sealed-related modifier is 
implicitly
+    // non-sealed; descendants past that boundary are unconstrained.
+    @Test
+    void testImplicitNonSealedPropagation() {
+        assertScript '''
+            sealed interface Shape permits Polygon, Circle {}
+            final class Circle implements Shape {}
+            class Polygon implements Shape {}          // implicit non-sealed
+            class RegularPolygon extends Polygon {}    // unrestricted
+            class Hexagon extends RegularPolygon {}    // unrestricted
+
+            assert new Circle() instanceof Shape
+            assert new Polygon() instanceof Shape
+            assert new RegularPolygon() instanceof Shape
+            assert new Hexagon() instanceof Shape
+        '''
+    }
+
+    // GEP-13: anonymous classes are not in the permits set and so cannot
+    // extend or implement a sealed type.
+    @Test
+    void testAnonymousClassNotPermitted() {
+        shouldFail(MultipleCompilationErrorsException, '''
+            sealed interface Shape permits Circle {}
+            final class Circle implements Shape {}
+            def x = new Shape() {}
+        ''')
+    }
+
+    // GEP-13: coercion-generated proxies must observe the permits set;
+    // 'x as Sealed' from a non-permitted source must fail.
+    @Test
+    void testCoercionFromNonPermittedSource() {
+        shouldFail '''
+            sealed interface Bar permits Foo {}
+            final class Foo implements Bar {}
+            def b = (new Object()) as Bar
+        '''
+    }
+
+    // GEP-13: @Sealed has RUNTIME retention; @NonSealed and @SealedOptions
+    // have SOURCE retention. EMULATE mode ensures @Sealed is the sole
+    // runtime carrier of sealed info, so its presence at runtime proves
+    // the retention contract.
+    @Test
+    void testAnnotationRetentions() {
+        assertScript '''
+            import groovy.transform.NonSealed
+            import groovy.transform.Sealed
+            import groovy.transform.SealedMode
+            import groovy.transform.SealedOptions
+
+            @SealedOptions(mode = SealedMode.EMULATE)
+            sealed class Shape permits Circle {}
+            @NonSealed class Circle extends Shape {}
+
+            assert Shape.getAnnotation(Sealed) != null
+            assert Circle.getAnnotation(NonSealed) == null
+            assert Shape.getAnnotation(SealedOptions) == null
+        '''
+    }
+
+    // GEP-13: @Delegate targeting a sealed type implicitly makes the
+    // enclosing class implement that sealed type, which is illegal when
+    // the enclosing class is not in the permits set.
+    // GEP-13: @Delegate must not introduce a non-permitted subtype of a
+    // sealed type. For sealed interfaces, DelegateASTTransformation omits
+    // the 'implements' clause (see GROOVY-7288); for sealed classes the
+    // wrapper never extends the target at all (single inheritance).
+    @Test
+    void testDelegateDoesNotMakeWrapperASealedSubtype() {
+        // Sealed interface — implicit and explicit interfaces=true.
+        assertScript '''
+            sealed interface Bar permits Foo {}
+            final class Foo implements Bar { void hi() {} }
+
+            class WrapperImplicit { @Delegate Bar inner = new Foo() }
+            class WrapperExplicit { @Delegate(interfaces=true) Bar inner = new 
Foo() }
+
+            assert !(new WrapperImplicit() instanceof Bar)
+            assert !WrapperImplicit.interfaces.contains(Bar)
+            assert !(new WrapperExplicit() instanceof Bar)
+            assert !WrapperExplicit.interfaces.contains(Bar)
+        '''
+        // Sealed class — wrapper neither extends nor implements it.
+        assertScript '''
+            sealed class Bar permits Foo { String name }
+            final class Foo extends Bar { Foo() { name = 'foo' } }
+
+            class Wrapper { @Delegate Bar inner = new Foo() }
+
+            assert Wrapper.superclass == Object
+            assert !(new Wrapper() instanceof Bar)
+        '''
+    }
 }


Reply via email to