G
YouTube TikTok Games
Skip to content

GROOVY-12614: static properties and accessors of traits (bundles 12 PRs) and a specification of member access in trait code - #3297

Open
paulk-asert wants to merge 16 commits into
apache:masterfrom
paulk-asert:trait-statics-bundle
Open

paulk-asert wants to merge 16 commits into
apache:masterfrom
paulk-asert:trait-statics-bundle

Conversation

@paulk-asert

Copy link
Copy Markdown
Contributor

This bundles twelve open fixes for static properties, static accessors and @Virtual statics in traits, so that their behaviour can be checked as a whole across dynamic, @TypeChecked and @CompileStatic code, and adds a specification of the rules to the traits guide. It targets master; which commits go to GROOVY_6_0_X is noted at the end.

The "Decisions taken" section lists what was put into this PR, not decisions of the Groovy project. Each bundled PR is one commit with its original author, so the bundle can be merged as it is, trimmed or squashed.

What is in it

One commit per PR, in this order:

JIRA PR Change
GROOVY-12590 #3115 SC: call a trait's static accessor read as a property with the implementing class
GROOVY-12596 #3123 STC: accept this.name for a static accessor of a trait in trait code
GROOVY-12608 #3135 SC: a super trait's static accessor read as a property in a closure
GROOVY-12605 #3132 a static accessor of a trait as a property in a static scope of the implementing class
GROOVY-12594 #3121 STC: a @Virtual static accessor read or written as a property
GROOVY-12609 #3136 STC: the remapped name of a super trait field in a closure or for an instance field
GROOVY-12639 #3162 convert a value written to a trait field as an assignment to the field
GROOVY-12597 #3124 report trait static state access through the trait itself clearly
GROOVY-12593 #3120 look up a static trait property in the trait, not in java.lang.Class
GROOVY-12598 #3125 a trait property referenced from a closure is resolved through the closure's delegate
GROOVY-12601 #3128 keep a @Virtual static call dynamic when Class declares a method of the same name
GROOVY-12604 #3131 use the trait's own @Virtual static when a promoted static is called through the trait

Three commits on top, described below: two that change behaviour and one for the guide, spec tests and a behaviour matrix.

Combining the PRs

Changes on top of the twelve PRs

1. A static accessor read as a property is a call of the accessor (GROOVY-12614)

For a trait with static String getLabel(), and an implementing class that declares its own static getLabel():

In a trait method body dynamic @TypeChecked @CompileStatic
label, this.label (before) class class trait
getLabel() (before and now) trait trait trait
label, this.label (now) trait trait trait

The guide and the @Virtual javadoc already say that a trait static is not overridden by the implementing class unless it is @Virtual. The property form now follows that in every mode: it is rewritten to the method form and dispatched as that is. Assignment calls the setter, and a compound assignment calls the setter with the value computed from the getter. With @Virtual the property form uses the implementing class's accessor, as the method form does (that needs GROOVY-12594 above under @TypeChecked and @CompileStatic).

TraitStaticDispatchMatrix.row09acc asserted the old dynamic behaviour for a plain accessor. It now uses @Virtual, and row09accp covers the plain accessor.

2. this.name for a declared trait property in a closure uses the accessor (follow-up to GROOVY-12598)

With #3125, the simple name of a declared instance property in a closure is resolved by the closure and reaches the accessor of the implementing instance. this.name in a closure still read and wrote the trait's backing field. It now uses the accessor too, as both forms do in a closure in a class that declares the property.

Decisions taken

  • A plain static accessor of a trait is not overridable from the implementing class in either form; @Virtual is the way to make it so. This changes dynamic and @TypeChecked code that relied on the property form (see COMPATIBILITY.md, which shows the @Virtual migration). A @Virtual static is not on the trait interface, so Trait.label is not available after migrating.
  • The existing rule that @Virtual does not apply to a call made from a closure or lambda (VirtualAnnotationTest.virtual_insideClosure_stillHelperBound) is kept and is now in the guide and the javadoc.
  • In a lambda, a declared instance property is the trait's backing field, as in a method body. The trait transformation cannot tell whether a lambda will be statically compiled, and a statically compiled lambda has no owner to ask for the property.

Not changed

Documentation

A new section of the traits guide, "Member access in trait code", states which accessor or field a name in trait code refers to: defined terms, five rules for instance members, five for static members, and a summary table for a method body, a closure and a lambda. A note names the two places above where the implementation does not follow the table yet.

  • The three spec tests behind the examples run as dynamic, @TypeChecked and @CompileStatic code.
  • TraitMemberAccessMatrix asserts the whole table: 7 kinds of member, 10 forms of reference, 3 modes. Cells where the implementation differs from the rule are listed with what it does; a cell that starts to follow the rule fails its test until it is removed from the list.
  • COMPATIBILITY.md has entries for the two behaviour changes.

Testing

  • Root :test: 20,181 tests, 0 failures. Subproject tests were not run locally.
  • The Checkstyle and CodeNarc gates pass, and the guide builds.
  • Every test method of every bundled PR is present.

GROOVY_6_0_X

The twelve PR commits cherry-pick onto GROOVY_6_0_X in order without conflicts; they have not been tested there. The two behaviour changes on top are meant for master only, and #3125 (GROOVY-12598) also changes behaviour, so it is a candidate to leave out of a backport.

Supersedes #3115, #3120, #3121, #3123, #3124, #3125, #3128, #3131, #3132, #3135, #3136 and #3162.

codeconsole and others added 15 commits November 13, 2026 20:49
…ith the implementing class (apache#3115)

Under @CompileStatic, trait code that reads or writes a static accessor of
a trait with property syntax ("ready" for isReady(), "cnt = 1" for
setCnt(int)) called the trait interface's forwarder, which passes the
trait itself as the receiver class, so the accessor failed to reach the
trait's static state. Call the trait helper with $static$self or
$self.getClass() instead, as the method call form already does.
…t in trait code (apache#3123)

In trait code, "this.ready" for a static "isReady()" of the trait is
compiled as "$self.ready" (or "$static$self.ready" in a static method of a
sub-trait), and the type checker reported "static method of interface T can
only be accessed with class qualifier", although "this" stands for the
implementing class, which has the static accessor. The implicit form
"ready" is checked the same way, but its error was dropped because the
generated expression has no source position. Skip the check when the
receiver is the trait's $self or $static$self parameter and the accessor
is declared by a trait.
…erty in a closure with the implementing class (apache#3135)

A closure or lambda in trait code leaves a property name that does not
resolve to a property of the trait itself unchanged (GROOVY-8049,
GROOVY-9386), so that the closure's delegate can provide it. Under
@CompileStatic a static accessor of a super trait reached that way
("cnt" for getCnt()) was called on the super trait interface, which
passes the trait itself to the helper as the receiver class, so an
accessor that reads trait state failed. A static setter written as a
property ("cnt = n") was rejected by the type checker, which looked for
setters on the receiver type only, where a static interface method is
not inherited.

In a closure, call the trait helper with the class of the closure's
thisObject, which is $self, or $static$self in a static trait method.
A lambda has no thisObject, so rewrite the name there to "$self.name"
or "$static$self.name", as for a property of the trait itself. Let trait
code see the static setters of a super trait.
…lf clearly (apache#3124)

A promoted trait static called as Trait.m() runs with the trait interface
as its static self. The trait has no static state of its own, so a read or
write of a static field failed with a MissingMethodException naming the
internal accessor (T__flag$get). The trait interface now declares those
accessors and they throw a MissingFieldException that says the state
belongs to each implementing class.
…va.lang.Class (apache#3120)

In a static trait method the receiver is typed Class<T>, and
TraitReceiverTransformer.tryGetFieldNode asked that type for the field
first. A static property named like a declared field of java.lang.Class
(name, module, componentType, packageName) resolved to that private field,
so the assignment called a setter named after Object
(java_lang_Object__name$set), which fails at run time when dynamic and is
rejected under @TypeChecked/@CompileStatic.
…of the accessor

In the body of a trait method, "label" and "this.label" for a static
accessor getLabel() of the trait were resolved through the implementing
class in dynamic and @TypeChecked code, so a static getLabel() of the
implementing class was used, while the method form getLabel() and the
property form under @CompileStatic used the trait's own accessor.

The property form is now rewritten to the method form, which is then
dispatched as any call of a trait static method is: to the trait's own
method, or through the implementing class for a @virtual method. An
assignment calls the setter and a compound assignment calls the setter
with the value computed from the getter. Names in a closure or lambda
are still left for the closure to resolve, since its delegate may
provide them, and ++ and -- are not changed.

TraitStaticDispatchMatrix row09acc asserted the old dynamic behaviour;
it now uses @virtual, and row09accp covers the plain accessor.
…es the accessor

A declared property of a trait referenced by its simple name in a
closure is resolved by the closure, and so reaches the accessor of the
implementing instance. The qualified form this.name in a closure still
read and wrote the trait's backing field. It is now a property access on
the implementing instance, as it is in a closure in a class that
declares the property. A lambda keeps the direct field reference.
Adds "Member access in trait code" to the traits guide: defined terms,
numbered rules for instance and static members, and a summary table of
what a property name or accessor call in trait code refers to in a
method body, a closure and a lambda. The rules cover GROOVY-12590,
GROOVY-12594, GROOVY-12596, GROOVY-12598, GROOVY-12605, GROOVY-12608
and GROOVY-12614.

The spec tests behind the examples run as dynamic, @TypeChecked and
@CompileStatic code. TraitMemberAccessMatrix asserts the whole table in
the three modes and records the cells where the implementation does not
yet follow the rules. The @virtual javadoc now says that the marker does
not apply to calls from a closure or lambda.
@codecov-commenter

codecov-commenter commented Nov 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.03352% with 50 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.5202%. Comparing base (0b455da) to head (3ef9259).

Files with missing lines Patch % Lines
...oovy/transform/trait/TraitReceiverTransformer.java 84.7953% 4 Missing and 22 partials ⚠️
...roovy/transform/stc/StaticTypeCheckingVisitor.java 78.9474% 0 Missing and 8 partials ⚠️
...oovy/transform/stc/TraitTypeCheckingExtension.java 85.7143% 1 Missing and 7 partials ⚠️
...groovy/classgen/asm/sc/StaticInvocationWriter.java 88.3721% 1 Missing and 4 partials ⚠️
...groovy/transform/trait/TraitASTTransformation.java 93.3333% 0 Missing and 2 partials ⚠️
...ovy/classgen/asm/sc/StaticTypesCallSiteWriter.java 66.6667% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##               master      #3297        +/-   ##
==================================================
+ Coverage     74.4894%   74.5202%   +0.0307%     
- Complexity      44906      45094       +188     
==================================================
  Files            1703       1703                
  Lines          152180     152501       +321     
  Branches        29166      29292       +126     
==================================================
+ Hits           113358     113644       +286     
  Misses          28408      28408                
- Partials        10414      10449        +35     
Files with missing lines Coverage Δ
...codehaus/groovy/classgen/VariableScopeVisitor.java 92.4051% <100.0000%> (+0.1097%) ⬆️
...ovy/classgen/asm/sc/StaticTypesCallSiteWriter.java 78.0992% <66.6667%> (+0.0453%) ⬆️
...groovy/transform/trait/TraitASTTransformation.java 92.5065% <93.3333%> (-0.0143%) ⬇️
...groovy/classgen/asm/sc/StaticInvocationWriter.java 78.3362% <88.3721%> (+0.8081%) ⬆️
...roovy/transform/stc/StaticTypeCheckingVisitor.java 88.0151% <78.9474%> (+0.0462%) ⬆️
...oovy/transform/stc/TraitTypeCheckingExtension.java 83.4646% <85.7143%> (+2.4519%) ⬆️
...oovy/transform/trait/TraitReceiverTransformer.java 86.7232% <84.7953%> (-2.0524%) ⬇️

... and 18 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@daniellansun daniellansun left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for gathering the twelve fixes and for writing the member-access rules down so carefully. The matrix, with the open cells that fail if they start to follow the rule, is a very useful companion to the new guide section. The GROOVY-12614 and GROOVY-12598 changes match the description in COMPATIBILITY.md, and the @Virtual migration example there is clear.

I would like to request two changes before this becomes the published specification, and I have a few further notes that I hope are useful.

1. Sticky inLambda and instance rule 4. When a regular { } closure is nested inside a lambda, inLambda stays true, so isClosurePropertyReference and isDeclaredInstancePropertyInClosure both return false. A declared instance property in that inner closure then goes to the backing field in all three compilation modes, which does not match instance rule 4. The reverse nesting (a lambda inside { }) already follows rule 5. I may have missed a test for this; the matrix and the NOTE do not cover it. If an implementing class's setter is used as a validation hook, this path would skip it. Could inLambda track only the innermost construct (the existing inLambdaOnly flag already answers a related question), and could a test for { name } / this.name inside a lambda, in all three modes, lock the intended rule? If the present behaviour is left as an open cell, I think the guide should say so plainly, including that the implementing-class accessor is not called.

2. Terms versus ++ / -- on a plain static accessor. The Terms say that an increment or decrement of p is a reference of the same form as the name it is applied to, and static rule 4 already treats assignment and compound assignment as accessor calls. Prefix and postfix expressions are marked as assignmentTarget and then passed to super.transform, so a plain static accessor is not rewritten — which COMPATIBILITY.md already records as deliberate. With a class override, assignment and += use the trait accessor (already tested), but ++ / -- in dynamic and @TypeChecked code use the class getter and setter, and under @CompileStatic the getter is the trait's and the setter is the class's. That mixed @CompileStatic result looks like the older property-form split that GROOVY-12614 left in place for increment and decrement, rather than a new regression. Would you be willing either to bring ++ / -- in line with +=, or to drop the synonymy claim from the Terms and lock the current behaviour with tests in all three modes?

Other notes, if you are willing to take them now or in a follow-up, are on the inline comments: the summary NOTE does not mention that a @Virtual static accessor uses the implementing class for label / this.label in a closure or lambda under @CompileStatic as well; the official GROOVY-12608 lambda-with-delegate test does not lock @CompileStatic (the same script returns 1 there); a super-trait static accessor in a statically compiled method body does not yet get the same invokestatic as the closure path; and the clearer MissingFieldException from GROOVY-12597 may be worth a COMPATIBILITY.md entry if that fix is also backported.

Comment on lines +276 to +279
boolean oldInClosure = inClosure, oldInLambdaOnly = inLambdaOnly, oldInLambda = inLambda;
inLambdaOnly = exp instanceof LambdaExpression && (!inClosure || inLambdaOnly);
inClosure = true;
inLambda = oldInLambda || exp instanceof LambdaExpression;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may have missed a test for this, but inLambda is set with oldInLambda || exp instanceof LambdaExpression, so it stays true when a regular { } closure is nested inside a lambda. isClosurePropertyReference and isDeclaredInstancePropertyInClosure then return false, and a declared instance property in that inner closure goes to the backing field in all three modes, which does not match instance rule 4. The reverse nesting already follows rule 5. If an implementing-class setter is used as a validation hook, this path would skip it.

Could this flag track only the innermost construct? A test for { name } and this.name inside a lambda, in all three modes, would make the intended rule visible. If the present behaviour is kept, I think the guide should say so plainly.

Comment on lines +259 to 266
} else if (exp instanceof PrefixExpression || exp instanceof PostfixExpression) {
Expression oldTarget = assignmentTarget;
assignmentTarget = exp instanceof PrefixExpression ? ((PrefixExpression) exp).getExpression() : ((PostfixExpression) exp).getExpression();
try {
return super.transform(exp);
} finally {
assignmentTarget = oldTarget;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a plain static accessor, ++ and -- are marked as assignmentTarget and then left to super.transform, so they are not rewritten as getter plus setter, unlike +=. With a class override, assignment and += use the trait accessor, but ++ / -- use the class getter and setter in dynamic and @TypeChecked code, and under @CompileStatic the getter is the trait's and the setter is the class's. That looks like the older property-form split left in place, as COMPATIBILITY.md already notes that increment and decrement are unchanged.

The Terms, however, say that increment and decrement have the same meaning as the name they apply to. Would you consider either bringing these in line with +=, or adjusting the Terms and adding tests in all three modes?

Comment thread src/spec/doc/_traits.adoc Outdated
Comment on lines +854 to +855
or `setP(v)`, with or without `this.`. An assignment, compound assignment, increment or decrement of `p` is a
reference of the same form as the name it is applied to.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As noted on the transformer, this synonymy does not currently hold for ++ and -- on a plain static accessor-only property. Could the Terms or static rule 4 mention that these are not brought in line with the new rule, unless the rewrite is completed?

Comment thread src/spec/doc/_traits.adoc Outdated
Comment on lines +986 to +989
* A _name_ (`p` or `this.p`) of a static accessor-only property in a closure or a lambda is resolved by the closure,
so that its delegate can supply the name. When no delegate does, dynamic and `@TypeChecked` code reach the
implementing class's accessor, with or without `@Virtual`, where the rules give the trait's own. The method form
`getP()` follows the rules in every mode.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be worth spelling out the @CompileStatic case for a @Virtual static accessor as well? TraitMemberAccessMatrix.virtualStaticAccessorOnlyProperty records that label and this.label in a closure or a lambda use the implementing class in all three modes, whereas a plain static accessor does so only in dynamic and @TypeChecked code. The table can stay as the rule; I think the NOTE should list that cell the same way the matrix does.

Comment on lines +2927 to +2948
@ParameterizedTest
@ValueSource(strings=['@CompileDynamic','@TypeChecked'])
void testSuperTraitStaticAccessorAsPropertyInLambdaWithDelegate(String mode) {
assertScript shell, """
class P {
int cnt = 42
}
$mode
trait T {
private static int count = 1
static int getCnt() { count }
static void setCnt(int n) { count = n }
}
$mode
trait U extends T {
int get(P p) { p.with((x) -> cnt) }
}
$mode
class C implements U {
}

assert new C().get(new P()) == 42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This official GROOVY-12608 case locks only @CompileDynamic and @TypeChecked, where the delegate supplies 42. The same script under @CompileStatic returns 1 (the delegate is ignored). Could @CompileStatic be locked as well, and the NOTE or an open cell record that difference?

Comment on lines +473 to +485
private static MethodNode findTraitHelperMethod(final MethodNode target) {
if (target.isStatic() && isTrait(target.getDeclaringClass())) {
Parameter[] parameters = target.getParameters();
for (MethodNode method : Traits.findHelper(target.getDeclaringClass()).getDeclaredMethods(target.getName())) {
Parameter[] helperParameters = method.getParameters();
if (method.isStatic() && helperParameters.length == parameters.length + 1
&& ClassHelper.isClassType(helperParameters[0].getType())
&& ParameterUtils.parametersEqual(Arrays.copyOfRange(helperParameters, 1, helperParameters.length), parameters)) {
return method;
}
}
}
return null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A super-trait static accessor in a statically compiled method body does not yet take the same invokestatic path as a closure. This lookup only runs when target.isStatic() && isTrait(target.getDeclaringClass()), and only when closureThis || isTraitSelf(receiver). transformMethodCallOnThis writes a super-trait getN() as this.getN($static$self) without setMethodTarget, so the writer sees the child helper. The result is still correct, but the method body goes through invokedynamic and boxing, while the closure path is already invokestatic on the super-trait helper. Aligning the two would need the call to be bound to the super-trait helper, not only a wider lookup. I mention this in case you want to take it in a follow-up.

Comment on lines +767 to +772
private static MethodNode createTraitStaticStateAccessor(final ClassNode traitClass, final FieldNode field, final String name, final Parameter[] params) {
String message = "Cannot access static field " + field.getName() + " of trait " + traitClass.getName()
+ " through the trait itself: trait static state belongs to each implementing class,"
+ " so call the trait static method on an implementing class";
Statement body = throwS(ctorX(ClassHelper.make(MissingFieldException.class),
args(constX(message), constX(field.getName()), classX(traitClass))));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failure mode through the trait itself is now a clearer MissingFieldException that uses the source field name and the trait name, and does not leak the helper name. The guide already describes this; would a short COMPATIBILITY.md entry be useful as well, especially if the fix is also taken to 6.0.x?

Comment on lines +844 to +859
private static boolean isStaticallyCompiled(final AnnotatedNode member, final ClassNode trait) {
for (AnnotatedNode node : new AnnotatedNode[]{member, trait}) {
boolean compileStatic = false;
for (AnnotationNode annotation : node.getAnnotations()) {
String name = annotation.getClassNode().getName();
Expression mode = annotation.getMember("value");
if (name.equals("groovy.transform.CompileDynamic") || (mode != null && mode.getText().equals("groovy.transform.TypeCheckingMode.SKIP")
&& (name.equals("groovy.transform.CompileStatic") || name.equals("groovy.transform.TypeChecked")))) {
return false;
}
compileStatic = compileStatic || name.equals("groovy.transform.CompileStatic");
}
if (compileStatic) return true;
}
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isStaticallyCompiled only looks at annotations on the member or the trait. A method body still ends up as invokestatic when @CompileStatic is applied only through a compiler customizer, so the main effect is whether the GROOVY-12608 statically compiled lambda rewrite is triggered. It may be enough to document or test that case.

…uses the accessor

Where closures and lambdas are nested in trait code, the innermost one
decides how a declared instance property is accessed: a closure within a
lambda now uses the accessor, as a closure does elsewhere. The simple
name is not offered to the closure's delegate there.

Also state in the user guide that an increment or a decrement of a static
accessor-only property is not a call of the trait's accessors, extend the
note on where compilation modes differ from the rules, and record the
GROOVY-12608 change for a statically compiled lambda in COMPATIBILITY.md.
Tests lock each of these in all compilation modes.
@paulk-asert

Copy link
Copy Markdown
Contributor Author

Thanks Daniel, all of these checked out. I've pushed 3ef9259 in response.

1. Closure within a lambda. Fixed: the innermost construct now decides, so a declared property in a closure within a lambda goes through the accessor for reads and writes, and a lambda within a closure still uses the field. testPropertyInClosureWithinLambdaUsesTheAccessor locks this in all three modes. One deviation is recorded in the NOTE: the simple name is not offered to the closure's delegate there (it behaves as this.name), because leaving it for the closure to resolve hits the GROOVY-12602 ClassCastException under @CompileStatic.

2. ++ / --. I've taken your second option. The Terms no longer claim these are the same form of reference, static rule 4 and the "Prefix and postfix operations" section say what happens, and testIncrementOfStaticAccessorIsNotACallOfTheTraitAccessor locks the current results in all three modes, including the mixed @CompileStatic one. Increment and decrement of a trait's own fields are still rejected (GROOVY-6689), and I'd rather handle both together there than complete half of it here.

Other notes.

  • The NOTE now lists the @Virtual cell for @CompileStatic.
  • GROOVY-12608 under @CompileStatic is locked (the lambda reads the accessor, so 1), and since master returns 42 there I've added a COMPATIBILITY.md entry for it.
  • The customizer case is locked too. It is a little worse than "document or test": a plain lambda () -> cnt fails with MissingFieldException when @CompileStatic comes from a customizer. Master fails there as well, so it is recorded as an open cell, not a regression.
  • The invokestatic alignment for a super-trait accessor in a method body I'll leave for a follow-up, as you suggest.
  • I've held back the COMPATIBILITY.md entry for the GROOVY-12597 message until the backport decision is made.

@testlens-app

testlens-app Bot commented Nov 14, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 3ef9259
▶️ Tests: 132593 executed
⚪️ Checks: 26/26 completed


Learn more about TestLens at testlens.app/docs.

@daniellansun daniellansun left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Paul. I have gone back over the whole of #3297 at 3ef9259, including the twelve bundled fixes, the later behaviour changes, the guide, and your follow-up to the earlier review.

The two changes from the earlier review are in place. The innermost construct now decides how a declared instance property is accessed when closures and lambdas are nested, testPropertyInClosureWithinLambdaUsesTheAccessor locks that in all three modes, and the NOTE and the GROOVY-12598 compatibility entry record that the simple name in a closure within a lambda is not offered to the delegate (because leaving it for the closure hits GROOVY-12602 under @CompileStatic). The Terms no longer treat increment and decrement as the same form of reference as assignment; static rule 4 and the “Prefix and postfix operations” section describe the present behaviour, and testIncrementOfStaticAccessorIsNotACallOfTheTraitAccessor locks it in all three modes, including the mixed @CompileStatic result. I agree it is better to handle ++ / -- on static accessors together with GROOVY-6689 than to complete only half of that here.

The other notes from last time look good as well. The summary NOTE now includes the @Virtual @CompileStatic cell; the official GROOVY-12608 lambda-with-delegate case is locked under @CompileStatic and has a compatibility entry; and the customizer path is tested and recorded as an open cell, including the MissingFieldException for a plain lambda () -> cnt. I am content to leave the super-trait invokestatic alignment in a method body, and a compatibility entry for the GROOVY-12597 message, for a later follow-up.

One residual case you may want to pick up later, or to list as an open cell: a SAM lambda nested in a closure does not yet take the GROOVY-12608 statically compiled rewrite, because that path still keys off inLambdaOnly (a lambda that is not inside a closure) rather than the innermost construct. A top-level Supplier s = () -> cnt under @CompileStatic correctly reads the super-trait accessor, but the same lambda inside { -> … } fails at run time with the GROOVY-12597 MissingFieldException. Dynamic and @TypeChecked code return the accessor value. This fails closed, and it is not covered by the official 12608 tests, so I do not think it needs to block this change. Using inLambda for that rewrite, or recording the nest in the NOTE with a locking test, would close the gap.

I also noticed that onMethodSelection always asks findVirtualStaticAccessorType for a read, unlike handleUnresolvedProperty. I could not turn that into a user-visible type-checking hole, so I am happy to leave it as an optional tidy-up.

I am happy to approve this version of the PR.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants