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 01:29:47 UTC

(groovy) branch master 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 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 e83dd19b61 GROOVY-12093 adjusted: out with @Anchored in with @Virtual
e83dd19b61 is described below

commit e83dd19b6181c23f3d47241b25edb33642c0a92c
Author: Paul King <pa...@asert.com.au>
AuthorDate: Fri Jun 26 18:43:09 2026 +1000

    GROOVY-12093 adjusted: out with @Anchored in with @Virtual
---
 src/main/java/groovy/transform/Anchored.java       |  70 ------
 src/main/java/groovy/transform/Virtual.java        |  59 +++++
 .../transform/trait/TraitASTTransformation.java    |  85 +++----
 .../transform/trait/TraitReceiverTransformer.java  |  23 +-
 .../groovy/transform/traitx/Groovy11985.groovy     |  15 +-
 .../groovy/transform/traitx/Groovy12093.groovy     | 270 ---------------------
 .../traitx/TraitStaticDispatchMatrix.groovy        | 193 ++++-----------
 .../transform/traitx/VirtualAnnotationTest.groovy  | 243 +++++++++++++++++++
 8 files changed, 408 insertions(+), 550 deletions(-)

diff --git a/src/main/java/groovy/transform/Anchored.java b/src/main/java/groovy/transform/Anchored.java
deleted file mode 100644
index 1666117b02..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 6.0.0
- */
-@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..e81b8924c1
--- /dev/null
+++ b/src/main/java/groovy/transform/Virtual.java
@@ -0,0 +1,59 @@
+/*
+ *  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 always
+ * 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. Unlike plain trait statics, an {@code @Virtual} method is
+ * not promoted to a JVM-native interface static on the trait
+ * interface — interface statics are declarer-bound by JVM rule and
+ * incompatible with virtual dispatch — so external callers reach the
+ * method through the implementer rather than via {@code Trait.m()}.
+ *
+ * @since 6.0.0
+ */
+@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 15cc9a8be1..f9b4e19db2 100644
--- a/src/main/java/org/codehaus/groovy/transform/trait/TraitASTTransformation.java
+++ b/src/main/java/org/codehaus/groovy/transform/trait/TraitASTTransformation.java
@@ -116,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);
 
     /**
      * Metadata key that marks trait-generated calls requiring dynamic dispatch.
@@ -268,18 +268,17 @@ 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);
+        validateVirtualAnnotations(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);
+        // Identify public static trait methods whose bodies the main loop
+        // will emit on the helper; each gets a JVM-native interface static
+        // method promoted onto the trait interface in a post-processing
+        // step after the originals are removed from the trait interface.
+        List<MethodNode> interfaceStatics = collectInterfaceStatics(cNode);
 
         // add methods
         List<MethodNode> nonPublicAPIMethods = new ArrayList<>();
@@ -310,12 +309,13 @@ public class TraitASTTransformation extends AbstractASTTransformation implements
         }
 
         // 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));
+        // public static trait method 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 m : interfaceStatics) {
+            cNode.addMethod(createInterfaceStaticForwarder(cNode, helper, m));
         }
 
         // copy statements from static and instance init blocks
@@ -651,15 +651,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";
@@ -670,47 +670,38 @@ public class TraitASTTransformation extends AbstractASTTransformation implements
             } else {
                 continue; // valid
             }
-            AnnotationNode anchored = annotations.get(0);
+            AnnotationNode at = 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()));
+                    at.getLineNumber(), at.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()}.
+     * Returns the public {@code static} non-abstract trait methods that
+     * should be promoted onto the generated trait interface as JVM-native
+     * interface static methods. Excludes {@code @Virtual} methods, whose
+     * dispatch path requires the helper-based dynamic-dispatch mechanism;
+     * promoting them onto the interface as direct static methods would
+     * make {@code @CompileStatic} callers bind to the trait's copy
+     * statically and bypass the virtual-dispatch path entirely.
+     *
+     * <p>The returned list snapshots the trait's method set so the caller
+     * can iterate without being affected by later mutations to
+     * {@code traitClass.getMethods()}.
      */
-    private static List<MethodNode> collectAnchoredOnInterface(final ClassNode traitClass) {
+    private static List<MethodNode> collectInterfaceStatics(final ClassNode traitClass) {
         List<MethodNode> result = new ArrayList<>();
         for (MethodNode methodNode : traitClass.getMethods()) {
             if (methodNode.isStatic() && !methodNode.isPrivate() && !methodNode.isAbstract()
-                    && isAnchoredOnInterface(methodNode)) {
+                    && methodNode.getAnnotations(VIRTUAL_TYPE).isEmpty()) {
                 result.add(methodNode);
             }
         }
         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.
@@ -719,9 +710,9 @@ public class TraitASTTransformation extends AbstractASTTransformation implements
      * 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.
+     * declarer-bound dispatch model of trait static methods.
      */
-    private static MethodNode createAnchoredInterfaceForwarder(final ClassNode traitClass, final ClassNode helper, final MethodNode original) {
+    private static MethodNode createInterfaceStaticForwarder(final ClassNode traitClass, final ClassNode helper, final MethodNode original) {
         Parameter[] params = original.getParameters();
         Expression[] callArgs = new Expression[params.length + 1];
         callArgs[0] = classX(traitClass);
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 0f7aa58aea..b338ff631f 100644
--- a/src/main/java/org/codehaus/groovy/transform/trait/TraitReceiverTransformer.java
+++ b/src/main/java/org/codehaus/groovy/transform/trait/TraitReceiverTransformer.java
@@ -67,7 +67,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;
@@ -359,23 +359,22 @@ 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
-                    // 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.
+                boolean virtual = !methodNode.getAnnotations(VIRTUAL_TYPE).isEmpty();
+                if (methodNode.isStatic() && !methodNode.isPrivate() && virtual && !inClosure) {
+                    // Default dispatch for trait static methods is
+                    // declarer-bound; per-implementer override visibility
+                    // is opt-in via `@Virtual`. Annotating a public trait
+                    // static with @Virtual emits the dynamic-dispatch
+                    // path so the implementer's override (if any) is
+                    // visible from trait code.
                     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..5a4628f07c 100644
--- a/src/test/groovy/org/codehaus/groovy/transform/traitx/Groovy11985.groovy
+++ b/src/test/groovy/org/codehaus/groovy/transform/traitx/Groovy11985.groovy
@@ -31,8 +31,9 @@ final class Groovy11985 {
     @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 275b6c93fd..0000000000
--- a/src/test/groovy/org/codehaus/groovy/transform/traitx/Groovy12093.groovy
+++ /dev/null
@@ -1,270 +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 {
-
-    // Basic-case coverage lives in TraitStaticDispatchMatrix:
-    //   * plain-static override visible to trait body  → matrix row 1
-    //   * @Anchored makes dispatch trait-anchored      → matrix row 1a
-    //   * @Anchored from-trait T.m()                   → matrix row 7a
-    //   * @Anchored external Trait.m()                 → matrix row 8a
-    //   * @Anchored(inInterface=false) opt-out         → matrix row 13
-    // This class covers the @Anchored-specific tests that the matrix
-    // doesn't carry: 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 9272734a36..bb5d9063a5 100644
--- a/src/test/groovy/org/codehaus/groovy/transform/traitx/TraitStaticDispatchMatrix.groovy
+++ b/src/test/groovy/org/codehaus/groovy/transform/traitx/TraitStaticDispatchMatrix.groovy
@@ -22,25 +22,16 @@
 // 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. 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.
 
 package org.codehaus.groovy.transform.traitx
 
@@ -141,9 +132,13 @@ class TraitStaticDispatchMatrix {
     // this row's polymorphic answer is the trait-machinery distinctive.
     @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() }
             }
@@ -154,29 +149,8 @@ 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 ----
@@ -259,75 +233,31 @@ class TraitStaticDispatchMatrix {
         assert r.c == 'class' && r.d == 'trait'
     }
 
-    // ---- 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.
+    // ---- Row 7 — T.m() trait-qualified from inside trait body ----
+    // Resolves at the JVM level via the trait interface's static method
+    // (public trait statics are promoted onto the trait interface). The
+    // trait's own copy wins; any same-named static on the implementer is
+    // an independent method, not an override (declarer-bound dispatch).
     @Test
-    void row07_traitQualified_observed() {
-        def outcome
-        try {
-            outcome = ev '''
-                trait T {
-                    static String who() { 'T' }
-                    static String viaTrait() { T.who() }
-                }
-                class C implements T { static String who() { 'C' } }
-                C.viaTrait()
-            '''
-        } 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)"
-        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() {
+    void row07_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})"
+        assert r == 'T' : "row7: T.m() from trait body must resolve to trait's own copy via interface static (got ${r})"
     }
 
-    // ---- Row 8 — external Impl.m() works; external T.m() unsupported ----
+    // ---- Row 8 — external Impl.m() works AND external Trait.m() works ----
+    // Public trait statics are JVM-native interface statics, so both
+    // forms of external access resolve cleanly.
     @Test
     void row08_externalAccess() {
         assert ev('trait T { static String foo() { "ok" } }\nclass C implements T {}\nC.foo()') == 'ok'
-        GroovyAssert.shouldFail {           // no statics on the trait interface
-            ev 'trait T { static String foo() { "x" } }\nT.foo()'
-        }
-    }
-
-    // ---- 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})"
+        assert ev('trait T { static String foo() { "ok" } }\nT.foo()') == 'ok'
     }
 
     // ---- Row 9 — static FIELD override NOT seen by trait (documented non-goal) ----
@@ -384,14 +314,20 @@ class TraitStaticDispatchMatrix {
     // as row 1, same fix in 5.0.7 / 6.0.0-alpha-2 via GROOVY-11985.
     @Test
     void row10_subclassAnchorsOnDirectImplementer() {
+        // m2 needs @Virtual for A's override to be visible to T's m1
+        // (per-implementer override visibility is opt-in).
         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() (m2 is @Virtual), 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"
     }
@@ -463,9 +399,15 @@ class TraitStaticDispatchMatrix {
     // post-fix 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 {}
@@ -476,54 +418,13 @@ class TraitStaticDispatchMatrix {
         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)"
     }
 
-    // ---- 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)"
-    }
-
-    // ---- Row 14 — T.this.* qualifier in trait code (GROOVY-12104) ----
-    // `T.this.m()` inside trait code is rejected at compile time. Without
-    // the reject, the call previously generated invalid bytecode
-    // (VerifyError on 4.x: "Class not assignable to Closure";
-    // ClassCastException at runtime on 5.x/6.x). See Groovy12104.groovy
-    // for focused coverage (instance variants + field-access form +
-    // regression-guards for explicit other-class qualifiers). This row is
-    // the matrix-level anchor.
+    // ---- Row 14 — T.this.* qualifier in trait code (proposed compile error) ----
+    // Latent bug surfaced during the GROOVY-12093 alternatives discussion.
+    // `T.this.m()` inside trait code currently produces invalid bytecode
+    // (VerifyError on 4.x: "Class not assignable to Closure"; ClassCastException
+    // at runtime on 5.x/6.x). GEP-22 § "this, super, and stackable traits"
+    // item 4 proposes rejecting the syntax at compile time. NYI until the
+    // fix lands; flips red on a build that implements the rejection.
     @Test
     void row14_T_this_qualifier_inTrait_isCompileError() {
         GroovyAssert.shouldFail(MultipleCompilationErrorsException) {
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..97612e9f21
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/transform/traitx/VirtualAnnotationTest.groovy
@@ -0,0 +1,243 @@
+/*
+ *  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 @groovy.transform.Virtual} marker on trait static
+ * methods.
+ *
+ * <p>Public trait static methods are promoted onto the generated trait
+ * interface as JVM-native interface statics and dispatched declarer-bound
+ * by default. Per-implementer override visibility is opt-in via
+ * {@code @Virtual}:
+ *
+ * <pre>
+ *   plain  static m()              -&gt; declarer-bound + on the interface (default)
+ *   {@literal @}Virtual static m()  -&gt; virtual via implementer
+ * </pre>
+ */
+final class VirtualAnnotationTest {
+
+    // ============ Default (no annotations) — declarer-bound ============
+
+    /** Plain {@code static}: trait body sees the trait's own copy, not the impl override. */
+    @Test
+    void plainStatic_isDeclarerBound_byDefault() {
+        assertScript '''
+            trait V {
+                static boolean defaultNullable() { false }
+                static boolean seen() { this.defaultNullable() }
+            }
+            class Over implements V { static boolean defaultNullable() { true } }
+
+            // Direct call: implementer's same-named static is independent.
+            assert Over.defaultNullable() == true
+
+            // Trait body: now sees only the trait's own copy (declarer-bound).
+            assert Over.seen() == false
+        '''
+    }
+
+    // ============ @Virtual — virtual dispatch via implementer ============
+
+    /** {@code @Virtual}: trait body dispatches through the implementer. */
+    @Test
+    void virtualStatic_overrideVisibleToTraitBody() {
+        assertScript '''
+            import groovy.transform.Virtual
+
+            trait V {
+                @Virtual
+                static boolean defaultNullable() { false }
+                static boolean seen() { this.defaultNullable() }   // trait-body call
+            }
+            class Over implements V { static boolean defaultNullable() { true } }
+            class Def  implements V { }
+
+            // Direct call: override always wins
+            assert Over.defaultNullable() == true
+
+            // Trait body sees the implementer's override
+            assert Over.seen() == true
+            assert Def.seen()  == false   // no override → trait default
+        '''
+    }
+
+    /**
+     * Worked example: a framework trait declares a {@code @Virtual} static
+     * default that user classes override by declaring a same-signature
+     * static. Trait-internal logic that consumes the value sees the
+     * implementer's override.
+     */
+    @Test
+    void virtual_overridableStaticDefault_workedExample() {
+        assertScript '''
+            import groovy.transform.Virtual
+
+            trait Validateable {
+                @Virtual
+                static boolean defaultNullable() { false }
+
+                // Trait-internal method that consumes the (potentially overridden) value
+                static boolean defaultNullableSeenByTrait() {
+                    this.defaultNullable()
+                }
+            }
+
+            class MyNullableCommand implements Validateable {
+                static boolean defaultNullable() { true }
+            }
+            class MyOtherCommand implements Validateable { /* uses default */ }
+
+            assert MyNullableCommand.defaultNullable() == true
+            assert MyNullableCommand.defaultNullableSeenByTrait() == true   // override visible
+            assert MyOtherCommand.defaultNullableSeenByTrait() == false     // trait default
+        '''
+    }
+
+    /** {@code @Virtual} is keyed on the callee, not the caller — same discipline as private static. */
+    @Test
+    void virtual_isKeyedOnCallee() {
+        assertScript '''
+            import groovy.transform.Virtual
+
+            trait V {
+                static       boolean fixed()        { false }   // plain → declarer-bound
+                @Virtual
+                static       boolean overridable()  { false }   // virtual
+                static List<Boolean> bothSeen() {
+                    [this.fixed(), this.overridable()]
+                }
+            }
+            class Over implements V {
+                static boolean fixed()       { true }
+                static boolean overridable() { true }
+            }
+            assert Over.bothSeen() == [false, true]   // fixed stays trait's, virtual sees override
+        '''
+    }
+
+    /** {@code @Virtual} under @CompileStatic also dispatches virtually. */
+    @Test
+    void virtualStatic_under_CompileStatic() {
+        assertScript '''
+            import groovy.transform.Virtual
+
+            @groovy.transform.CompileStatic
+            trait V {
+                @Virtual
+                static String name() { 'trait' }
+                static String seen() { this.name() }
+            }
+            @groovy.transform.CompileStatic
+            class Impl implements V { static String name() { 'impl' } }
+
+            assert Impl.seen() == 'impl'
+        '''
+    }
+
+    // ============ Validation ============
+
+    /** {@code @Virtual} on an instance method → compile error. */
+    @Test
+    void virtual_onInstanceMethod_isCompileError() {
+        def err = shouldFail(MultipleCompilationErrorsException) {
+            assertScript '''
+                import groovy.transform.Virtual
+                trait V {
+                    @Virtual
+                    def m() { 'V' }          // instance, not static
+                }
+                new Object()
+            '''
+        }
+        assert err.message.contains('@Virtual can only be applied to public static trait methods')
+        assert err.message.contains('is not static')
+    }
+
+    /** {@code @Virtual} on a private static → compile error. */
+    @Test
+    void virtual_onPrivateStatic_isCompileError() {
+        def err = shouldFail(MultipleCompilationErrorsException) {
+            assertScript '''
+                import groovy.transform.Virtual
+                trait V {
+                    @Virtual
+                    private static m() { 'V' }
+                }
+                new Object()
+            '''
+        }
+        assert err.message.contains('@Virtual can only be applied to public static trait methods')
+        assert err.message.contains('is private')
+    }
+
+
+    // ============ Closure asymmetry (unchanged from current spec) ============
+
+    /**
+     * Closure carve-out: a static call inside a closure body inside a trait method
+     * is helper-bound, even when the callee is {@code @Virtual}. This is the
+     * long-standing closure-context asymmetry; the {@code @Virtual} marker
+     * does not extend into closure scope.
+     */
+    @Test
+    void virtual_insideClosure_stillHelperBound() {
+        assertScript '''
+            import groovy.transform.Virtual
+
+            trait V {
+                @Virtual
+                static boolean defaultNullable() { false }
+                static boolean seenInClosure() {
+                    [1].collect { this.defaultNullable() }[0]   // closure body
+                }
+            }
+            class Over implements V { static boolean defaultNullable() { true } }
+
+            // The closure carve-out keeps the call helper-bound even with @Virtual.
+            // Same asymmetry as the current spec's row 2; documented as deliberate.
+            assert Over.seenInClosure() == false
+        '''
+    }
+
+    // ============ Instance method dispatch is unaffected (regression guard) ============
+
+    /** Instance methods dispatch polymorphically — the {@code @Virtual} marker is for trait statics only. */
+    @Test
+    void instanceMethods_dispatchUnchanged() {
+        assertScript '''
+            trait V {
+                def which() { 'trait' }
+                def greet() { this.which() }
+            }
+            class C implements V { def which() { 'class' } }
+            class D implements V { }
+
+            assert new C().greet() == 'class'   // override wins (standard polymorphism)
+            assert new D().greet() == 'trait'   // trait default
+        '''
+    }
+}