You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/06/27 04:43:22 UTC

(groovy) branch GROOVY_5_0_X updated: GROOVY-12093 adjusted: out with @Anchored in with @Virtual

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

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


The following commit(s) were added to refs/heads/GROOVY_5_0_X by this push:
     new 1ecbd77bdc GROOVY-12093 adjusted: out with @Anchored in with @Virtual
1ecbd77bdc is described below

commit 1ecbd77bdc4451fed9a189e3c64c51a8fc6e5c9d
Author: Paul King <pa...@asert.com.au>
AuthorDate: Sat Jun 27 11:50:48 2026 +1000

    GROOVY-12093 adjusted: out with @Anchored in with @Virtual
    
    Backport of master's post-review adjustment to the 5.0.x line. Replaces
    the unreleased groovy.transform.Anchored marker (added in this branch's
    GROOVY-12093 backport, never shipped) with groovy.transform.Virtual,
    AND flips the default dispatch shape for public trait statics so the
    two release lines stay aligned at the source level:
    
      default          declarer-bound (trait body sees its own copy)
      @Virtual         per-implementer override visible to trait body
    
    On 5.x the default returns to the pre-existing 4.x shape; @Virtual is
    the opt-in that restores the per-implementer override path used by
    Grails' Validateable.defaultNullable() and similar framework hooks.
    The annotated tests (Groovy11985 and matrix rows 1/10/12) add the
    marker so they exercise the per-implementer path under the new model.
    
    Out of scope for this backport (master-only on the 6.x line):
    GROOVY-12104 (reject T.this.* qualifier inside trait code),
    GROOVY-12105 (reject unqualified super.m() from a static trait method),
    and the Groovy-6 default of promoting public trait statics to
    JVM-native interface statics.
    
    The matrix test loses its four @Anchored-marker rows (1a/7a/8a/13)
    and its header references; rows 1/10/12 gain @Virtual on the relevant
    trait static so they continue to assert per-implementer dispatch. A
    new VirtualAnnotationTest covers both @Virtual's dispatch contract
    (with and without the marker, dynamic and @CompileStatic) and the
    validation that rejects misapplied placements at compile time.
---
 src/main/java/groovy/transform/Anchored.java       |  70 ------
 src/main/java/groovy/transform/Virtual.java        |  54 +++++
 .../transform/trait/TraitASTTransformation.java    | 111 +--------
 .../transform/trait/TraitReceiverTransformer.java  |  20 +-
 .../groovy/transform/traitx/Groovy11985.groovy     |  21 +-
 .../groovy/transform/traitx/Groovy12093.groovy     | 266 ---------------------
 .../traitx/TraitStaticDispatchMatrix.groovy        | 224 +++++------------
 .../transform/traitx/VirtualAnnotationTest.groovy  | 164 +++++++++++++
 8 files changed, 312 insertions(+), 618 deletions(-)

diff --git a/src/main/java/groovy/transform/Anchored.java b/src/main/java/groovy/transform/Anchored.java
deleted file mode 100644
index 2f4b369c68..0000000000
--- a/src/main/java/groovy/transform/Anchored.java
+++ /dev/null
@@ -1,70 +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 groovy.transform;
-
-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 trait method as <em>anchored to the trait</em> — dispatch is fixed
- * to the trait's own definition rather than routed through the implementing
- * class.
- *
- * <p>Applied to a trait <code>static</code> method, this opts out of the
- * default per-implementer override dispatch (the Groovy distinctive that the
- * Grails <code>Validateable.defaultNullable()</code> pattern depends on) and
- * substitutes the JVM interface-static dispatch model: the implementer's
- * same-signature static is an independent method, not an override; the trait
- * body always calls the trait's own copy.
- *
- * <p>By default the marker also promotes the annotated method onto the
- * generated trait interface as a JVM-native public static. That makes
- * external <code>Trait.m()</code> calls and from-trait <code>T.m()</code>
- * calls both resolve at the JVM level rather than throwing
- * <code>MissingMethodException</code>. The {@link #inInterface()} attribute
- * is provided as an opt-out for the narrow case where dispatch should be
- * trait-anchored but the method should <em>not</em> be published on the
- * interface (e.g. a soft-deprecated trait-internal helper that needs to
- * remain publicly callable for backward compatibility but should not gain a
- * fresh Java-visible API surface).
- *
- * <p>Use this marker for trait statics that are part of the trait's published
- * contract — guaranteed-invariant utilities the trait author does not want
- * implementers to redefine. Continue to use plain <code>static</code> when
- * the trait offers a default that implementers may legitimately override.
- *
- * @since 5.0.7
- */
-@Documented
-@Retention(RetentionPolicy.RUNTIME)
-@Target(ElementType.METHOD)
-public @interface Anchored {
-    /**
-     * Whether the annotated method should also be promoted onto the generated
-     * trait interface. Default <code>true</code>: the marker bundles
-     * declarer-bound dispatch with external interface visibility (the
-     * coherent JVM interface-static model). Set to <code>false</code> for the
-     * narrow opt-out described above — dispatch stays trait-anchored but the
-     * method is not exposed on the interface.
-     */
-    boolean inInterface() default true;
-}
diff --git a/src/main/java/groovy/transform/Virtual.java b/src/main/java/groovy/transform/Virtual.java
new file mode 100644
index 0000000000..a37bd9011d
--- /dev/null
+++ b/src/main/java/groovy/transform/Virtual.java
@@ -0,0 +1,54 @@
+/*
+ *  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 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 public trait <code>static</code> method as <em>virtual</em>:
+ * trait-body calls to this method dispatch through the implementing class
+ * at runtime, so a same-signature static method declared on the
+ * implementing class overrides the trait's default.
+ *
+ * <p>This is the opt-in counterpart to the default declarer-bound
+ * dispatch for trait static methods, where trait-body calls invoke the
+ * trait's own copy regardless of any same-named static on the
+ * implementer. The opt-in restores the per-implementer override pattern
+ * used by Grails' {@code Validateable.defaultNullable()} and similar
+ * framework hooks.
+ *
+ * <p>The marker is <em>per-callee</em>, not per-caller: it changes how
+ * trait code invokes the annotated method, regardless of which method
+ * inside the trait does the calling.
+ *
+ * <p>Valid only on public, non-abstract <code>static</code> trait
+ * methods. Applying it to an instance method, a private method, an
+ * abstract method, or anything outside a trait is a compile-time error.
+ *
+ * @since 5.0.7
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.METHOD)
+public @interface Virtual {
+}
diff --git a/src/main/java/org/codehaus/groovy/transform/trait/TraitASTTransformation.java b/src/main/java/org/codehaus/groovy/transform/trait/TraitASTTransformation.java
index 3ce40b577f..ce6bb59b2f 100644
--- a/src/main/java/org/codehaus/groovy/transform/trait/TraitASTTransformation.java
+++ b/src/main/java/org/codehaus/groovy/transform/trait/TraitASTTransformation.java
@@ -37,7 +37,6 @@ import org.codehaus.groovy.ast.MethodNode;
 import org.codehaus.groovy.ast.Parameter;
 import org.codehaus.groovy.ast.PropertyNode;
 import org.codehaus.groovy.ast.expr.BinaryExpression;
-import org.codehaus.groovy.ast.expr.ConstantExpression;
 import org.codehaus.groovy.ast.expr.Expression;
 import org.codehaus.groovy.ast.expr.MethodCallExpression;
 import org.codehaus.groovy.ast.expr.SpreadExpression;
@@ -117,8 +116,8 @@ import static org.objectweb.asm.Opcodes.ACC_SYNTHETIC;
 @GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS)
 public class TraitASTTransformation extends AbstractASTTransformation implements CompilationUnitAware {
 
-    /** Marker annotation type for {@code @Anchored} trait static methods. */
-    private static final ClassNode ANCHORED_TYPE = ClassHelper.make(groovy.transform.Anchored.class);
+    /** Marker annotation type for {@code @Virtual} trait static methods. */
+    private static final ClassNode VIRTUAL_TYPE = ClassHelper.make(groovy.transform.Virtual.class);
 
     public static final String DO_DYNAMIC = TraitReceiverTransformer.class + ".doDynamic";
     public static final String POST_TYPECHECKING_REPLACEMENT = TraitReceiverTransformer.class + ".replacement";
@@ -251,18 +250,11 @@ public class TraitASTTransformation extends AbstractASTTransformation implements
             processField(field, initializer, staticInitializer, fieldHelper, helper, staticFieldHelper, cNode, fieldNames);
         }
 
-        // Reject misapplied @Anchored markers before we waste effort
+        // Reject misapplied @Virtual markers before we waste effort
         // processing them. Errors are registered against the source unit but
         // processing continues so that multiple violations can be reported
         // in a single compilation.
-        validateAnchoredAnnotations(cNode);
-
-        // Identify @Anchored public statics whose bodies the main loop will
-        // emit on the helper. Captured up front so the main loop carries no
-        // @Anchored-specific branching and so the interface forwarders can be
-        // installed in a single post-processing step after the originals are
-        // removed from the trait interface.
-        List<MethodNode> anchoredOnInterface = collectAnchoredOnInterface(cNode);
+        validateVirtualAnnotations(cNode);
 
         // add methods
         List<MethodNode> nonPublicAPIMethods = new ArrayList<>();
@@ -292,15 +284,6 @@ public class TraitASTTransformation extends AbstractASTTransformation implements
             cNode.removeMethod(privateMethod);
         }
 
-        // Install a public-static method on the trait interface for each
-        // @Anchored callee identified above. The forwarder delegates to the
-        // helper so external `Trait.m()` and from-trait `T.m()` calls resolve
-        // at the JVM level. Done after the removal step so the original
-        // static method is no longer on cNode when the forwarder is added.
-        for (MethodNode anchored : anchoredOnInterface) {
-            cNode.addMethod(createAnchoredInterfaceForwarder(cNode, helper, anchored));
-        }
-
         // copy statements from static and instance init blocks
         if (staticInitStatements != null) {
             BlockStatement toBlock = getBlockStatement(staticInitializer, staticInitializer.getCode());
@@ -645,15 +628,15 @@ public class TraitASTTransformation extends AbstractASTTransformation implements
     }
 
     /**
-     * Reports a compile error for any {@code @Anchored} annotation that is
+     * Reports a compile error for any {@code @Virtual} annotation that is
      * applied to something other than a public static non-abstract trait
      * method. Without this check the misapplied annotation would be silently
      * ignored, leaving the user with no signal that the marker had no effect.
      */
-    private void validateAnchoredAnnotations(final ClassNode traitClass) {
+    private void validateVirtualAnnotations(final ClassNode traitClass) {
         for (MethodNode methodNode : traitClass.getMethods()) {
-            List<AnnotationNode> annotations = methodNode.getAnnotations(ANCHORED_TYPE);
-            if (annotations.isEmpty()) continue;
+            List<AnnotationNode> virtualAnns = methodNode.getAnnotations(VIRTUAL_TYPE);
+            if (virtualAnns.isEmpty()) continue;
             String issue;
             if (!methodNode.isStatic()) {
                 issue = "is not static";
@@ -664,84 +647,12 @@ public class TraitASTTransformation extends AbstractASTTransformation implements
             } else {
                 continue; // valid
             }
-            AnnotationNode anchored = annotations.get(0);
+            AnnotationNode virtual = virtualAnns.get(0);
             sourceUnit.addError(new SyntaxException(
-                    "@Anchored can only be applied to public static trait methods; "
+                    "@Virtual can only be applied to public static trait methods; "
                             + traitClass.getName() + "#" + methodNode.getTypeDescriptor() + " " + issue,
-                    anchored.getLineNumber(), anchored.getColumnNumber()));
-        }
-    }
-
-    /**
-     * Returns the public {@code static} trait methods whose {@code @Anchored}
-     * marker requests interface promotion (i.e. {@code inInterface=true}, the
-     * default). The returned list snapshots the trait's method set so the
-     * caller can iterate the methods without being affected by later
-     * mutations to {@code traitClass.getMethods()}.
-     */
-    private static List<MethodNode> collectAnchoredOnInterface(final ClassNode traitClass) {
-        List<MethodNode> result = new ArrayList<>();
-        for (MethodNode methodNode : traitClass.getMethods()) {
-            if (methodNode.isStatic() && !methodNode.isPrivate() && !methodNode.isAbstract()
-                    && isAnchoredOnInterface(methodNode)) {
-                result.add(methodNode);
-            }
+                    virtual.getLineNumber(), virtual.getColumnNumber()));
         }
-        return result;
-    }
-
-    /**
-     * Returns {@code true} if the method is annotated with {@code @Anchored}
-     * and the {@code inInterface} attribute is true (the default).
-     */
-    private static boolean isAnchoredOnInterface(final MethodNode methodNode) {
-        List<AnnotationNode> anns = methodNode.getAnnotations(ANCHORED_TYPE);
-        if (anns.isEmpty()) return false;
-        Expression member = anns.get(0).getMember("inInterface");
-        if (member instanceof ConstantExpression
-                && Boolean.FALSE.equals(((ConstantExpression) member).getValue())) {
-            return false;
-        }
-        return true;
-    }
-
-    /**
-     * Builds a public-static method on the trait interface that delegates to
-     * the corresponding helper method.
-     *
-     * <p>Emits {@code public static R m(args) { return T$Trait$Helper.m(T.class, args); }},
-     * preserving generics, exceptions and parameter list of the original
-     * trait static. The trait class itself is passed as the synthetic
-     * {@code $self} receiver expected by the helper, consistent with the
-     * declarer-bound dispatch model that {@code @Anchored} selects.
-     */
-    private static MethodNode createAnchoredInterfaceForwarder(final ClassNode traitClass, final ClassNode helper, final MethodNode original) {
-        Parameter[] params = original.getParameters();
-        Expression[] callArgs = new Expression[params.length + 1];
-        callArgs[0] = classX(traitClass);
-        for (int i = 0; i < params.length; i++) {
-            callArgs[i + 1] = varX(params[i]);
-        }
-        MethodCallExpression call = callX(classX(helper), original.getName(), args(callArgs));
-        Statement body = VOID_TYPE.equals(original.getReturnType()) ? stmt(call) : returnS(call);
-        MethodNode forwarder = new MethodNode(
-                original.getName(),
-                ACC_PUBLIC | ACC_STATIC,
-                original.getReturnType(),
-                params,
-                original.getExceptions(),
-                body);
-        forwarder.setGenericsTypes(original.getGenericsTypes());
-        forwarder.setSynthetic(true);
-        forwarder.setSourcePosition(original);
-        // Carry over the trait method's RUNTIME/CLASS-retention annotations (e.g.
-        // @Deprecated) so the promoted interface static behaves like the original,
-        // consistent with the forwarders generated by TraitComposer. The helper
-        // filters out SOURCE-retention/transform markers and closure-member ones.
-        List<AnnotationNode> copied = new ArrayList<>(), notCopied = new ArrayList<>();
-        GeneralUtils.copyAnnotatedNodeAnnotations(original, copied, notCopied);
-        forwarder.addAnnotations(copied);
-        return forwarder;
     }
 
     private MethodNode processMethod(final ClassNode traitClass, final ClassNode traitHelperClass, final MethodNode methodNode, final ClassNode fieldHelper, final Collection<String> knownFields) {
diff --git a/src/main/java/org/codehaus/groovy/transform/trait/TraitReceiverTransformer.java b/src/main/java/org/codehaus/groovy/transform/trait/TraitReceiverTransformer.java
index 9db8cf8d2d..cba6074f9a 100644
--- a/src/main/java/org/codehaus/groovy/transform/trait/TraitReceiverTransformer.java
+++ b/src/main/java/org/codehaus/groovy/transform/trait/TraitReceiverTransformer.java
@@ -66,7 +66,7 @@ import static org.codehaus.groovy.ast.tools.GeneralUtils.varX;
  */
 class TraitReceiverTransformer extends ClassCodeExpressionTransformer {
 
-    private static final ClassNode ANCHORED_TYPE = ClassHelper.make(groovy.transform.Anchored.class);
+    private static final ClassNode VIRTUAL_TYPE = ClassHelper.make(groovy.transform.Virtual.class);
 
     private final VariableExpression weaved;
     private final SourceUnit unit;
@@ -280,23 +280,21 @@ class TraitReceiverTransformer extends ClassCodeExpressionTransformer {
             MethodNode methodNode = findConcreteMethod(traitClass, call.getMethodAsString());
             if (methodNode != null) {
                 MethodCallExpression newCall;
-                boolean anchored = !methodNode.getAnnotations(ANCHORED_TYPE).isEmpty();
-                if (methodNode.isStatic() && !methodNode.isPrivate() && !anchored && !inClosure) {
-                    // GROOVY-11985: dispatch unqualified/this-qualified calls to
-                    // public, non-@Anchored trait statics through the
+                boolean virtual = !methodNode.getAnnotations(VIRTUAL_TYPE).isEmpty();
+                if (methodNode.isStatic() && !methodNode.isPrivate() && virtual && !inClosure) {
+                    // Opt-in via @Virtual: dispatch unqualified/this-
+                    // qualified calls to the trait static through the
                     // implementing class so an override declared on the
-                    // implementer is visible from trait code. Annotating the
-                    // trait static with @Anchored opts out of this override
-                    // path and keeps dispatch declarer-bound through the trait
-                    // helper (Java/interface-static flavour); the matching
-                    // interface promotion is performed in TraitASTTransformation.
+                    // implementer is visible from trait code. Plain
+                    // (un-annotated) public trait statics use the
+                    // declarer-bound helper path below instead.
                     Expression implClass = ClassHelper.isClassType(weaved.getOriginType()) ? varX(weaved) : castX(ClassHelper.CLASS_Type.getPlainNodeReference(), callX(varX(weaved), "getClass"));
                     newCall = callX(implClass, method, transform(arguments));
                     newCall.setImplicitThis(false);
                     newCall.putNodeMetaData(TraitASTTransformation.DO_DYNAMIC, methodNode.getReturnType());
                 } else {
                     // this.m(x) --> (this or T$Trait$Helper).m($self or $static$self or (Class)$self.getClass(), x)
-                    // Reached for: private static, @Anchored static, instance method, or any call inside a closure.
+                    // Reached for: plain (non-@Virtual) static, private static, instance method, or any call inside a closure.
                     Expression selfClassOrObject = methodNode.isStatic() && !ClassHelper.isClassType(weaved.getOriginType()) ? castX(ClassHelper.CLASS_Type.getPlainNodeReference(), callX(weaved, "getClass")) : weaved;
                     newCall = callX(!inClosure ? thisExpr : classX(traitHelper), method, createArgumentList(selfClassOrObject, arguments));
                 }
diff --git a/src/test/groovy/org/codehaus/groovy/transform/traitx/Groovy11985.groovy b/src/test/groovy/org/codehaus/groovy/transform/traitx/Groovy11985.groovy
index 8f95ddcd69..5116cfde8a 100644
--- a/src/test/groovy/org/codehaus/groovy/transform/traitx/Groovy11985.groovy
+++ b/src/test/groovy/org/codehaus/groovy/transform/traitx/Groovy11985.groovy
@@ -24,15 +24,16 @@ import org.junit.jupiter.api.Test
 
 final class Groovy11985 {
 
-    // Unqualified and `this.`-qualified public static calls inside a trait
-    // method must dispatch through the implementing class so an override on
-    // the implementer is visible from trait code.
+    // When a public trait static is marked @Virtual, unqualified and
+    // `this.`-qualified calls to it inside the trait body dispatch through
+    // the implementing class so an override on the implementer is visible.
 
     @Test
     void testStaticOverrideVisibleFromTraitThisCall() {
         GroovyAssert.assertScript '''
+            import groovy.transform.Virtual
             trait Validateable {
-                static boolean defaultNullable() { false }
+                @Virtual static boolean defaultNullable() { false }
                 static boolean defaultNullableSeenByTrait() { this.defaultNullable() }
             }
             class MyNullableValidateable implements Validateable {
@@ -49,8 +50,9 @@ final class Groovy11985 {
     @Test
     void testStaticOverrideVisibleFromTraitUnqualifiedCall() {
         GroovyAssert.assertScript '''
+            import groovy.transform.Virtual
             trait Validateable {
-                static boolean defaultNullable() { false }
+                @Virtual static boolean defaultNullable() { false }
                 static boolean defaultNullableUnqualified() { defaultNullable() }
             }
             class MyNullableValidateable implements Validateable {
@@ -65,8 +67,9 @@ final class Groovy11985 {
     @Test
     void testStaticOverrideVisibleFromInstanceMethod() {
         GroovyAssert.assertScript '''
+            import groovy.transform.Virtual
             trait T {
-                static String which() { 'trait' }
+                @Virtual static String which() { 'trait' }
                 String greet() { which() }
             }
             class C implements T {
@@ -82,9 +85,10 @@ final class Groovy11985 {
     void testStaticOverrideUnderCompileStatic() {
         GroovyAssert.assertScript '''
             import groovy.transform.CompileStatic
+            import groovy.transform.Virtual
             @CompileStatic
             trait Validateable {
-                static boolean defaultNullable() { false }
+                @Virtual static boolean defaultNullable() { false }
                 static boolean defaultNullableSeenByTrait() { this.defaultNullable() }
                 static boolean defaultNullableUnqualified() { defaultNullable() }
             }
@@ -117,7 +121,8 @@ final class Groovy11985 {
     @Test
     void testSuperTraitPublicStaticIsPolymorphic() {
         GroovyAssert.assertScript '''
-            trait Base { static String hello() { 'base' } }
+            import groovy.transform.Virtual
+            trait Base { @Virtual static String hello() { 'base' } }
             trait Mid extends Base { static String greet() { hello() } }
             class C implements Mid {}
             class D implements Mid { static String hello() { 'override' } }
diff --git a/src/test/groovy/org/codehaus/groovy/transform/traitx/Groovy12093.groovy b/src/test/groovy/org/codehaus/groovy/transform/traitx/Groovy12093.groovy
deleted file mode 100644
index c2074ab6f3..0000000000
--- a/src/test/groovy/org/codehaus/groovy/transform/traitx/Groovy12093.groovy
+++ /dev/null
@@ -1,266 +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 org.codehaus.groovy.transform.traitx
-
-import org.codehaus.groovy.control.MultipleCompilationErrorsException
-import org.junit.Test
-
-import static groovy.test.GroovyAssert.assertScript
-import static groovy.test.GroovyAssert.shouldFail
-
-/**
- * Exercises the {@code @Anchored} marker on trait static methods (GROOVY-12093).
- *
- * <p>The marker selects declarer-bound dispatch (Java/interface-static
- * flavour) in place of the default override-flavour dispatch, and (by
- * default) also promotes the annotated method onto the generated trait
- * interface.
- *
- * <pre>
- *   plain  static m()              -&gt; override flavour (dispatch via
- *                                      implementer; Grails Validateable
- *                                      canary; Groovy 4.0.32 + post-#2529)
- *   {@literal @}Anchored static m()           -&gt; declarer-bound dispatch + on the
- *                                      generated trait interface
- *   {@literal @}Anchored(inInterface=false)
- *     static m()                   -&gt; declarer-bound dispatch only, not
- *                                      published on the interface
- * </pre>
- *
- * Dispatch is implemented in {@code TraitReceiverTransformer}; interface
- * promotion is implemented in {@code TraitASTTransformation}.
- */
-final class Groovy12093 {
-
-    // This class covers the @Anchored-specific behaviour: that the marker
-    // makes dispatch trait-anchored, the from-trait T.m() and external
-    // Trait.m() resolution, the inInterface=false opt-out, per-callee
-    // keying, @CompileStatic variants, the inInterface=false STC
-    // compile-error case, and validation that @Anchored is rejected on
-    // non-public-static targets.
-
-    /**
-     * The marker is keyed on the <em>callee</em>, not the caller — same
-     * discipline as the existing private-static escape.
-     */
-    @Test
-    void anchored_isKeyedOnCallee() {
-        assertScript '''
-            import groovy.transform.Anchored
-
-            trait V {
-                static       boolean overridable() { false }
-                @Anchored
-                static       boolean anchored()    { false }
-                // Same caller, different callees: result depends on each
-                // callee's own marker, not the caller's.
-                static List<Boolean> bothSeen() { [this.overridable(), this.anchored()] }
-            }
-            class Over implements V {
-                static boolean overridable() { true }
-                static boolean anchored()    { true }
-            }
-            assert Over.bothSeen() == [true, false]
-        '''
-    }
-
-    /** Plain {@code static} inside {@code @CompileStatic} still routes via the implementer. */
-    @Test
-    void plainStatic_compileStatic_overrideVisible() {
-        assertScript '''
-            @groovy.transform.CompileStatic
-            trait V {
-                static String name() { 'trait' }
-                static String seen() { this.name() }
-            }
-            @groovy.transform.CompileStatic
-            class Impl implements V { static String name() { 'impl' } }
-
-            assert Impl.seen() == 'impl'
-        '''
-    }
-
-    /** {@code @Anchored} inside {@code @CompileStatic} stays trait-anchored. */
-    @Test
-    void anchored_compileStatic_traitAnchored() {
-        assertScript '''
-            import groovy.transform.Anchored
-
-            @groovy.transform.CompileStatic
-            trait V {
-                @Anchored
-                static String name() { 'trait' }
-                static String seen() { this.name() }
-            }
-            @groovy.transform.CompileStatic
-            class Impl implements V { static String name() { 'impl' } }
-
-            assert Impl.seen() == 'trait'
-        '''
-    }
-
-    /**
-     * STC sanity check: external {@code V.name()} from a {@code @CompileStatic}
-     * caller type-checks and runs — the interface static is visible to the
-     * static type checker, not just to dynamic dispatch.
-     */
-    @Test
-    void anchored_compileStatic_externalTraitDotM_typeChecks() {
-        assertScript '''
-            import groovy.transform.Anchored
-            import groovy.transform.CompileStatic
-
-            trait V {
-                @Anchored
-                static String name() { 'trait' }
-            }
-            class Impl implements V { }
-
-            @CompileStatic
-            static String callExternally() {
-                V.name()        // must type-check against the interface static
-            }
-
-            assert callExternally() == 'trait'
-        '''
-    }
-
-    /**
-     * STC sanity check: from-trait {@code V.m()} from inside a
-     * {@code @CompileStatic} trait body also type-checks against the
-     * interface static.
-     */
-    @Test
-    void anchored_compileStatic_fromTraitT_DotM_typeChecks() {
-        assertScript '''
-            import groovy.transform.Anchored
-
-            @groovy.transform.CompileStatic
-            trait V {
-                @Anchored
-                static String name() { 'trait' }
-                static String forced() { V.name() }   // trait-qualified, under @CS
-            }
-            @groovy.transform.CompileStatic
-            class Over implements V { static String name() { 'over' } }
-
-            assert Over.forced() == 'trait'
-        '''
-    }
-
-    /**
-     * STC sanity check: with the {@code inInterface=false} opt-out, external
-     * {@code V.name()} from a {@code @CompileStatic} caller should fail to
-     * compile — no interface static to bind against.
-     */
-    @Test
-    void anchored_inInterfaceFalse_compileStatic_externalCall_isCompileError() {
-        shouldFail(MultipleCompilationErrorsException) {
-            new GroovyShell().evaluate '''
-                import groovy.transform.Anchored
-                import groovy.transform.CompileStatic
-
-                trait V {
-                    @Anchored(inInterface=false)
-                    static String name() { 'trait' }
-                }
-                class Impl implements V { }
-
-                @CompileStatic
-                static String callExternally() {
-                    V.name()       // not on the interface — should be a compile error under @CS
-                }
-                callExternally()
-            '''
-        }
-    }
-
-    /**
-     * The generated interface forwarder for an {@code @Anchored} static should
-     * carry the original method's runtime-retention annotations (e.g.
-     * {@code @Deprecated}), matching the forwarders {@code TraitComposer} creates
-     * for ordinary trait methods.
-     */
-    @Test
-    void anchored_propagatesRuntimeAnnotationsToInterfaceForwarder() {
-        assertScript '''
-            import groovy.transform.Anchored
-            import java.lang.reflect.Modifier
-
-            trait T {
-                @Deprecated
-                @Anchored
-                static String m() { 'T' }
-            }
-
-            def m = T.getDeclaredMethod('m')
-            assert Modifier.isStatic(m.modifiers)
-            assert m.isAnnotationPresent(Deprecated)
-        '''
-    }
-
-    // ---- Validation: @Anchored must target a public static (non-abstract) trait method ----
-
-    /** {@code @Anchored} on an instance method must be a compile error. */
-    @Test
-    void anchored_onInstanceMethod_isCompileError() {
-        def err = shouldFail '''
-            import groovy.transform.Anchored
-            trait V {
-                @Anchored
-                def m() { 'V' }            // instance, not static
-            }
-            new Object()
-        '''
-        assert err.message.contains('@Anchored can only be applied to public static trait methods')
-        assert err.message.contains('is not static')
-    }
-
-    /** {@code @Anchored} on a private static method must be a compile error. */
-    @Test
-    void anchored_onPrivateStatic_isCompileError() {
-        def err = shouldFail '''
-            import groovy.transform.Anchored
-            trait V {
-                @Anchored
-                private static m() { 'V' }
-            }
-            new Object()
-        '''
-        assert err.message.contains('@Anchored can only be applied to public static trait methods')
-        assert err.message.contains('is private')
-    }
-
-    /** {@code @Anchored} on an abstract trait method (which is implicitly
-     * non-static — Groovy rejects {@code abstract static}) surfaces the same
-     * "not static" diagnostic. */
-    @Test
-    void anchored_onAbstractInstanceMethod_isCompileError() {
-        def err = shouldFail '''
-            import groovy.transform.Anchored
-            trait V {
-                @Anchored
-                abstract m()
-            }
-            new Object()
-        '''
-        assert err.message.contains('@Anchored can only be applied to public static trait methods')
-        assert err.message.contains('is not static')
-    }
-}
diff --git a/src/test/groovy/org/codehaus/groovy/transform/traitx/TraitStaticDispatchMatrix.groovy b/src/test/groovy/org/codehaus/groovy/transform/traitx/TraitStaticDispatchMatrix.groovy
index c91285a1f8..a29f66bc5a 100644
--- a/src/test/groovy/org/codehaus/groovy/transform/traitx/TraitStaticDispatchMatrix.groovy
+++ b/src/test/groovy/org/codehaus/groovy/transform/traitx/TraitStaticDispatchMatrix.groovy
@@ -18,29 +18,19 @@
  */
 
 // Trait-static dispatch behaviour matrix — the executable companion to
-// GEP-22 § Static members. Three plain-class baseline rows (A/B/C), the
-// 12 design rows from GEP-22-progression-analysis.html (with row 11
+// GEP-22 § Static members. Three plain-class baseline rows (A/B/C) and
+// the 12 design rows from GEP-22-progression-analysis.html (with row 11
 // expanded to rows 11/11c/11esc covering the trait-shadows-superclass
 // quadrant + the T.super.m() escape; row 9 paired with row 9acc covering
-// the static-field non-goal and its accessor workaround), and four
-// @Anchored-marker rows characterising the GROOVY-12093 proposal.
+// the static-field non-goal and its accessor workaround).
 //
 // Run via gradle: `./gradlew :test --tests '*.TraitStaticDispatchMatrix'`
 //
-// Two kinds of test:
-//  * Matrix tests assert OBSERVED behaviour. They pass on every
-//    currently-shipping Groovy that delivers the spec behaviour and turn
-//    RED on releases that deviate (e.g. rows 1/10/12 on Groovy 5.0.0–
-//    5.0.6 / 6.0.0-alpha-1, which carry the GROOVY-8854 regression
-//    corrected by GROOVY-11985 in 5.0.7 / 6.0.0-alpha-2). That red is
-//    the intended bug-detector signal, not a defect in the test.
-//  * The four @Anchored rows (1a/7a/8a/13) assert the GROOVY-12093
-//    behaviour now delivered by `groovy.transform.Anchored` (added in
-//    this change); each references the annotation directly.
-//
-// Verdicts encoded here are PROPOSED PENDING TEAM REVIEW where the
-// underlying design point is not yet ratified (the four @Anchored rows
-// in particular).
+// Matrix tests assert OBSERVED behaviour. Rows that depend on per-
+// implementer override visibility (rows 1, 10, 12) opt in via
+// `@groovy.transform.Virtual` on the relevant trait static — plain
+// `static` is declarer-bound, `@Virtual` restores the override-via-
+// implementer path (the dispatch shape in 4.x).
 
 package org.codehaus.groovy.transform.traitx
 
@@ -62,18 +52,16 @@ class TraitStaticDispatchMatrix {
     //
     // VERIFIED on 4.0.32: plain Groovy static dispatch is declarer-bound in
     // every context (method body, closure body, dynamic, @CompileStatic);
-    // only INSTANCE methods are polymorphic. Trait row 1's "override visible
-    // to trait method body" is therefore a deliberate departure from this
-    // baseline (the per-implementer $static$self mechanism); the row 2
-    // closure carve-out keeps trait closure-context dispatch consistent
-    // with this baseline. The simplified-model RULE wording "dispatch like
-    // a plain class member" was an aspirational fiction — the empirical
-    // truth is more nuanced and the baseline below records it.
+    // only INSTANCE methods are polymorphic. Public trait statics match
+    // this baseline by default; the row 1 departure (override visible to
+    // trait body) is the OPT-IN behaviour of `@groovy.transform.Virtual`,
+    // which routes the call through the implementing class via the per-
+    // implementer $static$self mechanism.
 
     // ---- Baseline A — plain class static, this.foo() in METHOD BODY ----
     // The control for trait row 1. Plain Groovy: declarer-bound ('base').
-    // Trait row 1 returns 'true' (override visible) — that's the trait
-    // distinctive, NOT a plain-class semantic.
+    // Trait row 1 returns 'true' (override visible) — that requires
+    // @Virtual; without it the trait matches this baseline.
     @Test
     void baseline_A_plainStatic_methodBody_isDeclarerBound() {
         def r = ev '''
@@ -111,8 +99,8 @@ class TraitStaticDispatchMatrix {
     // Confirms ordinary instance dispatch IS polymorphic in closure context
     // (row 6 in the trait matrix is the instance-method analogue). Only
     // static dispatch falls back to declarer-bound. Together baselines A/B/C
-    // show: trait row 1's override-via-implementer in method body is a
-    // deliberate Groovy-trait distinctive — neither plain statics nor plain
+    // show: row 1's override-via-implementer (when opted into with @Virtual)
+    // is a Groovy-trait distinctive — neither plain statics nor plain
     // instance methods give that shape in closure context unaided.
     @Test
     void baseline_C_plainInstance_closureBody_isPolymorphic() {
@@ -131,17 +119,22 @@ class TraitStaticDispatchMatrix {
 
     // ---- Row 1 — public static, this./unqualified, impl overrides ----
     // Scenario: overridable static defaults (Grails Validateable.defaultNullable).
-    // SPEC-NORMATIVE end-state (GEP-22 § Static members, item 4): override
-    // visible to trait code. Holds on 4.x (always did) and on 5.0.7+ /
-    // 6.0.0-alpha-2+ (where GROOVY-11985 corrects the GROOVY-8854 regression).
-    // Red on 5.0.0–5.0.6 and 6.0.0-alpha-1 = bug detector for those releases.
-    // Contrast baseline A (plain class same shape — declarer-bound, 'base'):
-    // this row's polymorphic answer is the trait-machinery distinctive.
+    // The trait static is marked @Virtual to opt into the per-implementer
+    // override path; plain trait statics (no marker) are declarer-bound
+    // and would return the trait's own copy regardless of any same-named
+    // static on the implementer — see VirtualAnnotationTest for the
+    // un-annotated control. Contrast baseline A (plain class same shape —
+    // declarer-bound, 'base'): the @Virtual marker makes the trait answer
+    // polymorphic at the spot a plain class is not.
     @Test
     void row01_publicStatic_overrideSeenByTrait() {
+        // Per-implementer override visibility is opt-in via @Virtual.
+        // Plain `static` is declarer-bound; @Virtual makes the trait body's
+        // dispatch route through the implementing class.
         def r = ev '''
+            import groovy.transform.Virtual
             trait V {
-                static boolean defaultNullable() { false }
+                @Virtual static boolean defaultNullable() { false }
                 static boolean seenThis() { this.defaultNullable() }
                 static boolean seenUnqualified() { defaultNullable() }
             }
@@ -152,39 +145,15 @@ class TraitStaticDispatchMatrix {
         '''
         assert r.direct == true            // direct call: override always wins
         assert r.defThis == false          // row 1': no override -> trait default
-        assert r.overThis == true : "row1 this. : override must be visible to trait body, got ${r.overThis} — on 5.0.0–5.0.6 / 6.0.0-alpha-1 this is the GROOVY-8854 regression (fixed in 5.0.7 / 6.0.0-alpha-2 by GROOVY-11985)"
-        assert r.overUnq  == true : "row1 unqual: override must be visible to trait body, got ${r.overUnq}"
-    }
-
-    // ---- Row 1a — @Anchored static, trait body sees trait's own copy ----
-    // The dispatch half of the @Anchored marker. With the annotation,
-    // `this.m()`/`m()` in the trait body should dispatch to the trait's own
-    // copy regardless of any implementer override (the JVM/interface-static
-    // model — Eric's "static implies final" use case). Unmet on every
-    // shipping Groovy (annotation does not exist there); met on the spike.
-    @Test
-    void row01a_anchored_dispatchIsTraitAnchored() {
-        def r = ev '''
-            import groovy.transform.Anchored
-            trait V {
-                @Anchored
-                static boolean defaultNullable() { false }
-                static boolean seen() { this.defaultNullable() }
-            }
-            class Over implements V { static boolean defaultNullable() { true } }
-            Over.seen()
-        '''
-        assert r == false : "row1a @Anchored: trait body should always see the trait's own copy (got ${r})"
+        assert r.overThis == true : "row1 this. : @Virtual override must be visible to trait body, got ${r.overThis}"
+        assert r.overUnq  == true : "row1 unqual: @Virtual override must be visible to trait body, got ${r.overUnq}"
     }
 
     // ---- Row 2 — same as #1 but the call is inside a closure ----
-    // The closure carve-out. Helper-bound on every version (long-standing,
-    // not a 5/6 regression). Matches baseline B (plain class same shape —
-    // declarer-bound, 'base'): the carve-out aligns trait closure-context
-    // dispatch with plain-Groovy semantics. Removing it would extend the
-    // trait-machinery distinctive into closures, further from plain Groovy
-    // not closer — the simplified-model "Move 3" proposal was retired
-    // after the baseline analysis surfaced this.
+    // The closure carve-out: a call from inside a closure body is
+    // helper-bound regardless of @Virtual. Matches baseline B (plain
+    // class same shape — declarer-bound, 'base'): the carve-out aligns
+    // trait closure-context dispatch with plain-Groovy semantics.
     @Test
     void row02_publicStatic_insideClosure() {
         def r = ev '''
@@ -195,7 +164,7 @@ class TraitStaticDispatchMatrix {
             class Over implements V { static boolean defaultNullable() { true } }
             Over.seenInClosure()
         '''
-        assert r == false : "row2 closure: expected false on every version (Groovy ${MAJOR}), got ${r} — carve-out aligns with baseline B"
+        assert r == false : "row2 closure: expected false (Groovy ${MAJOR}), got ${r} — carve-out aligns with baseline B"
     }
 
     // ---- Row 3 — private trait static is NOT overridable by the impl ----
@@ -258,10 +227,8 @@ class TraitStaticDispatchMatrix {
     }
 
     // ---- Row 7 — T.m() trait-qualified inside trait body ----
-    // Throws MissingMethodException on every version (the trait interface
-    // carries no statics — GEP-22 § Static members, item 9). The desired
-    // "force trait default" escape this could represent is provided by
-    // @Anchored via interface promotion — see row 7a.
+    // Throws MissingMethodException on every shipping 5.x (the trait
+    // interface carries no statics — GEP-22 § Static members, item 9).
     @Test
     void row07_traitQualified_observed() {
         def outcome
@@ -277,29 +244,10 @@ class TraitStaticDispatchMatrix {
         } catch (Throwable t) {
             outcome = "THREW:${t.class.simpleName}"
         }
-        println "row07 observed on Groovy ${MAJOR}: ${outcome}  (T.m() from trait body throws on every version; @Anchored fixes it — see row 7a)"
+        println "row07 observed on Groovy ${MAJOR}: ${outcome}"
         assert outcome == 'THREW:MissingMethodException' : "row7 expected MissingMethodException: ${outcome}"
     }
 
-    // ---- Row 7a — @Anchored: T.m() from trait body resolves via interface static ----
-    // Interface promotion side-effect. With @Anchored, the trait static is
-    // also added to the trait interface, so the "force trait default" escape
-    // T.m() actually resolves (row 7 above throws on every shipping version).
-    @Test
-    void row07a_anchored_traitQualified_resolvesViaInterfaceStatic() {
-        def r = ev '''
-            import groovy.transform.Anchored
-            trait T {
-                @Anchored
-                static String who() { 'T' }
-                static String viaTrait() { T.who() }
-            }
-            class C implements T { static String who() { 'C' } }
-            C.viaTrait()
-        '''
-        assert r == 'T' : "row7a @Anchored: T.m() from trait body should resolve via interface static (got ${r})"
-    }
-
     // ---- Row 8 — external Impl.m() works; external T.m() unsupported ----
     @Test
     void row08_externalAccess() {
@@ -309,25 +257,6 @@ class TraitStaticDispatchMatrix {
         }
     }
 
-    // ---- Row 8a — @Anchored: external Trait.m() resolves via interface static ----
-    // Interface promotion main effect. Jochen's "the static method is not added
-    // to the interface. That should be a bug." With @Anchored, external
-    // T.m() resolves to the JVM-native interface static — the row 8 "external
-    // T.m() unsupported" limitation no longer applies for marker-bearing methods.
-    @Test
-    void row08a_anchored_externalTraitDotM_works() {
-        def r = ev '''
-            import groovy.transform.Anchored
-            trait T {
-                @Anchored
-                static String foo() { 'ok' }
-            }
-            class C implements T {}
-            T.foo()
-        '''
-        assert r == 'ok' : "row8a @Anchored: external T.m() should resolve via interface static (got ${r})"
-    }
-
     // ---- Row 9 — static FIELD override NOT seen by trait (documented non-goal) ----
     // A bare static-field reference in a trait body reads the trait's
     // template field, not any same-named static on the implementing class.
@@ -378,18 +307,20 @@ class TraitStaticDispatchMatrix {
     // so B.m1() still dispatches m2 through A's forwarder. Row 1's
     // override-visible dispatch flows through here: A's override of m2 IS
     // visible to m1 (returns 'A.m2'); B's own m2 is NOT (it would require
-    // re-composition). Red on 5.0.0–5.0.6 / 6.0.0-alpha-1 — same regression
-    // as row 1, same fix in 5.0.7 / 6.0.0-alpha-2 via GROOVY-11985.
+    // re-composition).
     @Test
     void row10_subclassAnchorsOnDirectImplementer() {
+        // The override-visible part requires @Virtual on the callee (m2);
+        // without it m1's call to m2 is declarer-bound and returns 'T.m2'.
         def r = ev '''
-            trait T { static String m2() { 'T.m2' }; static String m1() { m2() } }
+            import groovy.transform.Virtual
+            trait T { @Virtual static String m2() { 'T.m2' }; static String m1() { m2() } }
             class A implements T { static String m2() { 'A.m2' } }
             class B extends A    { static String m2() { 'B.m2' } }
             [ a: A.m1(), b: B.m1(), bDirect: B.m2() ]
         '''
         assert r.bDirect == 'B.m2'
-        assert r.a == 'A.m2' : "row10 a: A's override must be visible to A.m1(), got ${r.a} — see row 1 note on the GROOVY-8854 regression"
+        assert r.a == 'A.m2' : "row10 a: A's override must be visible to A.m1(), got ${r.a}"
         assert r.b == 'A.m2' : "row10 b: B.m1() must anchor on A (the direct implementer), got ${r.b}"
         assert r.b != 'B.m2' : "row10 load-bearing: B's own m2 must never win — dispatch anchors on direct implementer A, not the receiver class"
     }
@@ -452,65 +383,32 @@ class TraitStaticDispatchMatrix {
     }
 
     // ---- Row 12 — overload across trait/impl under @CompileStatic ----
-    // A consequence of row 1's dispatch path: GROOVY-11985 also shifts this
-    // overload case from '22' (pre-fix G5/G6 behaviour) back to '21' (G4's
-    // long-standing answer, and the post-fix answer on 5.0.7 / 6.0.0-alpha-2).
-    // Red on 5.0.0–5.0.6 / 6.0.0-alpha-1 — same regression boundary as
-    // rows 1 and 10. Jochen flagged both G4 and G5 as "imho wrong" but the
-    // team has not settled an alternative; this row locks the current
-    // post-fix outcome so any further change is surfaced for discussion.
+    // A consequence of row 1's dispatch path: when the callee is @Virtual
+    // the trait-side overload set is widened by what the implementer
+    // declares, so A.call2(X2) resolves to A.m(X2) rather than T.m(X1).
+    // Without @Virtual the trait static is declarer-bound and both calls
+    // would return '2'. Jochen flagged the @Virtual outcome here as
+    // "imho wrong" but the team has not settled an alternative; this row
+    // locks the current outcome so any further change is surfaced for
+    // discussion.
     @Test
     void row12_overloadUnderCompileStatic_observed() {
+        // Overload dispatch through the implementer requires @Virtual;
+        // without it the trait's m(X1) is declarer-bound and always wins.
         def out = ev '''
+            import groovy.transform.Virtual
             @groovy.transform.CompileStatic
-            trait T { static int m(X1 x){ 2 }; static int call2(X1 x){ m(x) } }
+            trait T {
+                @Virtual static int m(X1 x){ 2 }
+                static int call2(X1 x){ m(x) }
+            }
             @groovy.transform.CompileStatic
             class A implements T { static int m(X2 x){ 1 } }
             class X1 {}
             class X2 extends X1 {}
             "" + A.call2(new X1()) + A.call2(new X2())
         '''
-        println "row12 observed on Groovy ${MAJOR}: ${out} (expected '21' on 4.x and on 5.0.7+ / 6.0.0-alpha-2+)"
-        assert out == '21' : "row12: expected '21' (post-GROOVY-11985), got '${out}' — on 5.0.0–5.0.6 / 6.0.0-alpha-1 this returns '22' (the regression boundary)"
+        assert out == '21' : "row12 @Virtual: expected '21', got '${out}'"
     }
 
-    // ---- Row 13 — @Anchored(inInterface=false) opt-out ----
-    // The narrow escape: dispatch stays trait-anchored (like row 1a) but the
-    // interface static is suppressed, so external T.m() continues to fail
-    // (the row 8 behaviour persists for this method). Useful for soft-
-    // deprecated trait-internal helpers that need to stay publicly callable
-    // via the existing Impl.m() forwarder but should not gain a fresh
-    // Java-visible interface API surface.
-    @Test
-    void row13_anchored_inInterfaceFalse_optsOutOfInterfacePromotion() {
-        // Part 1: trait-body dispatch is still trait-anchored
-        def r = ev '''
-            import groovy.transform.Anchored
-            trait V {
-                @Anchored(inInterface=false)
-                static String name() { 'trait' }
-                static String seen() { this.name() }
-            }
-            class Impl implements V { static String name() { 'impl' } }
-            Impl.seen()
-        '''
-        assert r == 'trait' : "row13 inInterface=false: dispatch should still be trait-anchored (got ${r})"
-
-        // Part 2: external V.name() must still throw — no interface static generated
-        boolean externalThrows = false
-        try {
-            ev '''
-                import groovy.transform.Anchored
-                trait V {
-                    @Anchored(inInterface=false)
-                    static String name() { 'trait' }
-                }
-                class Impl implements V {}
-                V.name()
-            '''
-        } catch (Throwable ignored) {
-            externalThrows = true
-        }
-        assert externalThrows : "row13 inInterface=false: external V.name() should still fail (no interface static)"
-    }
 }
diff --git a/src/test/groovy/org/codehaus/groovy/transform/traitx/VirtualAnnotationTest.groovy b/src/test/groovy/org/codehaus/groovy/transform/traitx/VirtualAnnotationTest.groovy
new file mode 100644
index 0000000000..38952c0dc4
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/transform/traitx/VirtualAnnotationTest.groovy
@@ -0,0 +1,164 @@
+/*
+ *  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.traitx
+
+import groovy.test.GroovyAssert
+import org.junit.Test
+
+/**
+ * Coverage for the {@code @Virtual} marker. Plain public trait statics
+ * are declarer-bound (trait-body calls always invoke the trait's own
+ * copy regardless of any same-named static on the implementer);
+ * annotating the trait static with {@code @Virtual} opts into the
+ * per-implementer override path used by Grails-style framework hooks.
+ *
+ * <p>Two dimensions are covered: validation (misapplied markers are
+ * rejected at compile time) and dispatch (with vs without the marker,
+ * across dynamic and {@code @CompileStatic} call sites).
+ */
+final class VirtualAnnotationTest {
+
+    // ============ Default (no annotation) — declarer-bound ============
+
+    @Test
+    void plainStatic_isDeclarerBound_dispatch() {
+        GroovyAssert.assertScript '''
+            trait V {
+                static boolean defaultNullable() { false }
+                static boolean seen() { this.defaultNullable() }
+            }
+            class Over implements V { static boolean defaultNullable() { true } }
+            assert Over.seen() == false              // trait's own copy
+            assert Over.defaultNullable() == true    // direct call: override wins
+        '''
+    }
+
+    @Test
+    void plainStatic_isDeclarerBound_underCompileStatic() {
+        GroovyAssert.assertScript '''
+            import groovy.transform.CompileStatic
+            @CompileStatic
+            trait V {
+                static boolean defaultNullable() { false }
+                static boolean seen() { this.defaultNullable() }
+            }
+            @CompileStatic class Over implements V { static boolean defaultNullable() { true } }
+            assert Over.seen() == false
+        '''
+    }
+
+    // ============ @Virtual — virtual dispatch via implementer ============
+
+    @Test
+    void virtual_dispatchesThroughImplementer() {
+        GroovyAssert.assertScript '''
+            import groovy.transform.Virtual
+            trait V {
+                @Virtual static boolean defaultNullable() { false }
+                static boolean seen() { this.defaultNullable() }
+            }
+            class Over implements V { static boolean defaultNullable() { true } }
+            class Def  implements V { }
+            assert Over.seen() == true               // override visible to trait body
+            assert Def.seen()  == false              // no override -> trait default
+        '''
+    }
+
+    @Test
+    void virtual_unqualifiedCall_dispatchesThroughImplementer() {
+        GroovyAssert.assertScript '''
+            import groovy.transform.Virtual
+            trait V {
+                @Virtual static boolean defaultNullable() { false }
+                static boolean seenUnqualified() { defaultNullable() }
+            }
+            class Over implements V { static boolean defaultNullable() { true } }
+            assert Over.seenUnqualified() == true
+        '''
+    }
+
+    @Test
+    void virtual_underCompileStatic() {
+        GroovyAssert.assertScript '''
+            import groovy.transform.CompileStatic
+            import groovy.transform.Virtual
+            @CompileStatic
+            trait V {
+                @Virtual static boolean defaultNullable() { false }
+                static boolean seen() { this.defaultNullable() }
+            }
+            @CompileStatic class Over implements V { static boolean defaultNullable() { true } }
+            @CompileStatic class Def  implements V { }
+            assert Over.seen() == true
+            assert Def.seen()  == false
+        '''
+    }
+
+    @Test
+    void virtual_fromInstanceMethod() {
+        GroovyAssert.assertScript '''
+            import groovy.transform.Virtual
+            trait T {
+                @Virtual static String which() { 'trait' }
+                String greet() { which() }
+            }
+            class C implements T { static String which() { 'class' } }
+            class D implements T {}
+            assert new C().greet() == 'class'
+            assert new D().greet() == 'trait'
+        '''
+    }
+
+    // ============ Validation ============
+
+    @Test
+    void virtual_onInstanceMethod_isCompileError() {
+        def err = GroovyAssert.shouldFail '''
+            trait T {
+                @groovy.transform.Virtual
+                String hello() { 'x' }
+            }
+        '''
+        assert err.message.contains('@Virtual can only be applied to public static trait methods')
+        assert err.message.contains('is not static')
+    }
+
+    @Test
+    void virtual_onPrivateStatic_isCompileError() {
+        def err = GroovyAssert.shouldFail '''
+            trait T {
+                @groovy.transform.Virtual
+                private static String hidden() { 'x' }
+            }
+        '''
+        assert err.message.contains('@Virtual can only be applied to public static trait methods')
+        assert err.message.contains('is private')
+    }
+
+    @Test
+    void virtual_onAbstractMethod_isCompileError() {
+        def err = GroovyAssert.shouldFail '''
+            abstract trait T {
+                @groovy.transform.Virtual
+                abstract String hello()
+            }
+        '''
+        assert err.message.contains('@Virtual can only be applied to public static trait methods')
+    }
+}