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)
+ '''
+ }
}