GEP-21


Metadata
Number

GEP-21

Title

Broadening AST transform awareness under joint compilation

Version

7

Type

Feature

Status

Draft

Leader

Paul King

Created

2026-04-28

Last modification

2026-04-30

Note
A working spike for Shape C (the split-transform model described below) is available at https://github.com/apache/groovy/pull/2503. It covers @Sortable, @TupleConstructor, @MapConstructor, @NamedVariant, @Singleton, @Bindable, @Vetoable, @ListenerList, @ExternalizeMethods, @IndexedProperty, @ToString, @EqualsAndHashCode, @Lazy, @Final, @AutoClone, @Builder, @AutoImplement, @InheritConstructors, @Delegate, @RecordType — end-to-end through joint compilation, plus compositional tests for @Canonical and @Immutable. Native records are additionally handled by a stub-side back-channel (GROOVY-11974). The narrow remaining edges (transform-added abstract methods on same-unit supertypes; sub-declared-first ordering when a sibling stubber on the super hasn’t run yet) are documented inline alongside each affected transform’s spike-results entry. Findings are folded into this revision.

Abstract

This GEP extends Groovy’s AST transform framework so that opt-in transforms can contribute Java-visible members to a class sufficiently early that they appear in the Java stubs used by joint compilation. The injected output of an opt-in transform — new constructors, methods, fields, declared interfaces, modifier adjustments — is visible to javac at compile time and to any downstream Java tooling that consumes the stubs.

The proposal is strictly additive. Transforms that do not opt in behave exactly as today. Programs that compile under Groovy 6/5 continue to compile under this proposal with identical bytecode and identical stubs. The change makes a class of programs that fail joint compilation today succeed.

The proposal targets Groovy 7.x (some parts could be 6.x?).

Motivation

Joint compilation lets a project mix .groovy and .java sources in one compilation unit. The pipeline is:

  1. Groovy parses and converts every source.

  2. At Phases.CONVERSION, Groovy emits Java stubs — empty-bodied Java files containing the public API surface of each Groovy class.

  3. javac compiles the Java sources together with the stubs.

  4. Groovy resumes, re-resolves names against the now-compiled Java classes, and continues through later phases until bytecode is written.

The stubs are the only contract javac ever sees for the Groovy classes. Whatever isn’t in the stub doesn’t exist as far as the Java sources are concerned.

The current gap

Local AST transforms run no earlier than Phases.SEMANTIC_ANALYSIS. Stubs are emitted earlier, at Phases.CONVERSION. The output of any local transform that injects members — @TupleConstructor, @MapConstructor, @Singleton, @Bindable, @Vetoable, @ListenerList, @Builder, @Delegate, @InheritConstructors, @IndexedProperty, @ExternalizeMethods, @Sortable, @NamedVariant, @Immutable, records — is therefore not in the stub. The injected members appear in the runtime .class file but not in the API contract that javac reads.

The user-visible symptom is a Java source that fails to compile against a Groovy class whose runtime form would satisfy the call:

// UserAccount.groovy
@groovy.transform.Immutable
class UserAccount {
    String name
    int age
}
// Caller.java
public class Caller {
    public UserAccount build() {
        return new UserAccount("alice", 30);    // compile error: no such constructor
    }
}

The runtime class has the constructor; the stub does not; javac sees only the stub.

Existing relief

Three mechanisms already paper over the gap for narrow cases:

  • The stub generator runs a stripped-down Verifier sub-pass that synthesises property accessors and the groovy.lang.GroovyObject glue from PropertyNode entries declared in source. Plain String name becomes getName() / setName(String) in the stub.

  • JavaStubGenerator reads the @PackageScope annotation directly to adjust class / field / constructor / method modifiers in the stub even though PackageScopeASTTransformation itself runs at SEMANTIC_ANALYSIS.

  • JavaStubGenerator recognises traits via Traits.isTrait(…​) and renders a @Trait-annotated class as an interface plus the appropriate trait-method shape.

These are stub-side back-channels: targeted code in JavaStubGenerator that knows about a specific annotation or classifier. They work, but each one is a per-transform concession rather than a general mechanism. Adding @TupleConstructor to JavaStubGenerator the same way would mean re-implementing the constructor-generation logic in two places, with the same drift risk as any duplicated specification.

The opportunity

Most transforms in the affected list have signatures that are fully determined by information available at Phases.CONVERSION: declared fields and their types, declared class generics, declared annotations, classpath supertypes. The bodies — which are typically the only part requiring later phases — are irrelevant in stubs, because stub bodies always have placeholder content (return null;, throw …​) regardless of the runtime body.

A general way for a transform to declare "my member signatures are knowable at CONVERSION; please render them into the stub even though my body work runs later" would close the gap once for all such transforms, removes the pressure to keep adding stub-side back- channels, and lets joint-compilation projects use the standard library’s transforms without working around the joint-compile contract.

Design principles

  • Strictly additive. Transforms that don’t opt in behave exactly as today. No existing program changes meaning.

  • Opt-in. The transform author decides whether their transform is joint-compile-aware. The framework imposes no obligation.

  • Stub fidelity. When a transform opts in, the API surface in the stub matches the API surface of the runtime class. The bodies do not — bodies are always placeholders in stubs, by design.

  • No new phases. The mechanism slots into the existing Phases.CONVERSION op chain alongside the resolver and the transform-collector visitor. Transform dispatch for declared phases (SEMANTIC_ANALYSIS, CANONICALIZATION, …) is unchanged.

  • Reuse existing machinery. @AnnotationCollector aliases are already expanded in place at CONVERSION by the transform- collector visitor; the new mechanism inherits that for free. Priority ordering between transforms (TransformWithPriority) is reused for ordering of stub contributions.

  • No global re-architecture. Cross-class same-unit dependencies and late-phase type inference remain out of reach for stub contributions, by design (see Excluded and deferred features). Solving those would require a fixed-point scheduling model that is much larger in scope than this proposal.

Features

Stub generation timeline (recap)

The order of operations at Phases.CONVERSION for joint compilation is, per source unit:

  1. ResolutionJavaAwareResolveVisitor resolves type names in class headers, super, interfaces, field types, parameter / return types, and annotation types. Method bodies are deliberately not walked at this point.

  2. Annotation collection and alias expansionASTTransformationCollectorCodeVisitor finds every transform-bearing annotation on the AST, expands @AnnotationCollector aliases in place, and records which transforms apply at which phases.

  3. Stub generationJavaStubGenerator walks each non-private top-level class node and writes a .java stub, running an embedded Verifier sub-pass to synthesise property accessors and the GroovyObject glue.

Local AST transforms then dispatch at their declared phase, starting no earlier than SEMANTIC_ANALYSIS.

This proposal inserts a fourth step at CONVERSION, after annotation collection / alias expansion and before stub generation:

  1. Stub contribution — for each class node, the framework gives every joint-compile-aware annotation an early invocation. The invocation may mutate the AST in any way that influences what JavaStubGenerator will subsequently render: adding members (interfaces, methods, constructors, fields) with placeholder bodies, flipping modifiers on existing members so the embedded Verifier sub-pass produces a different accessor shape, augmenting or trimming property nodes, and so on. Mutations are typically tagged with a metadata key so the authoritative later invocation — the same transform’s normal-phase call in Shapes A and B, or the sibling transform’s call in Shape C — can recognise the early contribution and complete it (replacing a placeholder body, discarding a stub-only artifact, etc.).

When stub generation runs (step 3 in the original chain, now step 5), it sees the post-contribution AST and renders the stub from that.

Joint-compile-aware transforms

A transform opts into the new behaviour by declaring itself joint- compile-aware. Three alternative shapes have been identified for how a transform expresses this opt-in. They are not mutually exclusive — the framework could support any one of them, or any combination — and the choice between them is a design question this GEP leaves open for discussion (see Open question: which shapes to ship? below). The shapes are presented here so the discussion has concrete options to weigh.

// Shape A — annotation attribute (re-entrant single-transform model)
@GroovyASTTransformation(
    phase = CompilePhase.CANONICALIZATION,
    jointCompileAware = true
)
public class TupleConstructorASTTransformation
        extends AbstractASTTransformation { ... }
// Shape B — separate interface (split-method model)
@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
public class TupleConstructorASTTransformation
        extends AbstractASTTransformation
        implements StubContributor { ... }
// Shape C — split-transform model (existing Groovy idiom, validated by spike)
@GroovyASTTransformationClass({
    "org.codehaus.groovy.transform.TupleConstructorASTStubber",       // phase = CONVERSION
    "org.codehaus.groovy.transform.TupleConstructorASTTransformation" // phase = CANONICALIZATION
})
public @interface TupleConstructor { ... }

In Shape A, the framework looks for the joint-compile-aware flag twice — once at CONVERSION during joint processing and once at the transform’s declared phase. The transform’s visit is invoked in both passes. The transform must be idempotent: state carried between calls lives on the AST nodes (e.g. via putNodeMetaData). A transform may distinguish the two passes via a context flag if the work is genuinely different, but this is not required by the framework.

In Shape B, the framework invokes contributeStubs(…​) at CONVERSION and the existing visit(…​) at the declared phase as today. The two methods are independent code paths in the same class.

In Shape C, the author writes two transform classes and lists both in @GroovyASTTransformationClass. The first class declares phase = CompilePhase.CONVERSION and runs only during joint compilation; the second class declares its normal phase (CANONICALIZATION, SEMANTIC_ANALYSIS, etc.) and is the authoritative pass for runtime output. Existing split-transform pairs in the codebase (SealedASTTransformation / SealedCompletionASTTransformation, ExternalizeMethodsASTTransformation / ExternalizeVerifierASTTransformation) follow the same idiom but both at non-CONVERSION phases; Shape C extends that pattern by allowing the first piece at CONVERSION.

Equivalence and trade-offs

All three shapes face the same scoping decision at CONVERSION (thin AST, no transform-cascade output, no static-type-checker results), the same subset-of-runtime invariant for stub fidelity, and the same idempotency requirement. They differ only in:

  • Visible API surface. Shapes A and B add it (a new annotation attribute, a new interface). Shape C adds none — it composes existing public mechanisms.

  • Boundary visibility. Shape C makes the early/late split externally obvious because each phase is its own class. Shapes A and B keep the split internal to a single transform class — equally valid, just less immediately legible to a reader.

  • Code reuse for shared logic. In all three shapes, when the early pass and the authoritative pass do overlapping work, a static helper called from both pieces handles the duplication cleanly (the spike demonstrates this with addComparableSurface shared between SortableASTStubber and SortableASTTransformation). This is ordinary refactoring, not a shape-specific tax.

Whichever shapes are ultimately supported, the contract observed by the rest of the compiler — what runs when, on which target, with which information available — is identical, and a transform’s choice of shape carries no on-the-wire compatibility consequence for users of that transform.

Note
The "Stubber" naming convention used in the spike is just a naming convention. Where the early piece does substantial AST surgery (e.g. LazyASTStubber flipping a property modifier to suppress setter emission), a more neutral name such as *ASTConversionPart or *ASTPreStub may better reflect scope. The framework imposes no naming requirement.

Open question: which shapes to ship?

The three shapes can co-exist in the framework — adding any one does not preclude later adding the others — but committing to all three from day one is a larger surface area than may be warranted. Two reasonable positions for the GEP discussion:

  • Ship Shape C only (the spike’s choice). It requires no new public API — only the small framework adjustments documented under Compilation — and it composes existing mechanisms (@GroovyASTTransformationClass lists, the standard transform- collector and dispatcher). Shapes A and B can be added later if concrete demand emerges or if some transform’s needs aren’t comfortably expressible as a split.

  • Ship Shape A or B in addition if the in-class re-entrant model is judged worth the visible API cost (a new annotation attribute or a new interface), for example because authors of complex transforms find the single-class model significantly easier to reason about.

This GEP does not pre-decide. Reviewers are invited to weigh in on which shapes to commit to in the first implementation.

Spike results: before / after stub views

The spike at https://github.com/apache/groovy/pull/2503 implements Shape C for every transform listed below. The table summarises what a Java source in the same joint-compilation unit sees via the generated stub before and after each transform opts in.

Transform Before (no stubber piece) After (Shape C stubber)

@Sortable

Class does not declare Comparable; no compareTo method is visible. Java code calling task.compareTo(other) fails to compile even though the runtime supports it.

implements java.lang.Comparable<Self> is on the class header; int compareTo(Self other) { return 0; } is declared. Java code compiles and at runtime hits the real comparison body (metadata-key handoff at CANONICALIZATION).

@TupleConstructor

No tuple constructor visible — only the implicit no-arg. Java code writing new Foo("bar") fails to compile even though defaults = true would generate the overload at runtime.

For defaults = true/defaultsMode = ON: the full prefix-overload chain (Foo(), Foo(p1), …​, Foo(p1..pN)) is in the stub, mirroring what Verifier will produce at runtime. For defaults = false/defaultsMode = OFF/AUTO: only the maximal form, matching the runtime exactly. (Trait-injected and super properties remain invisible — same-unit cross-class limit.)

@Lazy

Stub has both getX() and setX(…​) (auto-property accessors). Runtime has only getX(). Java code calling holder.setX(v) compiles against the stub but fails at runtime with NoSuchMethodError.

Stub has only getX() — the stubber marks the property final at CONVERSION, which causes Verifier to skip setter generation. The full transform later replaces the property entirely, so the final marker has no effect on runtime.

@ToString

toString() is inherited from Object only — the class does not declare it. Java code chaining super.toString() resolves to Object.toString() at the source level, even though the runtime class has its own override. Tooling that distinguishes declared from inherited methods sees no override.

toString() is declared on the class with a placeholder body in the stub. Java’s super.toString() chains correctly through Object-virtual dispatch to the runtime body, and tooling that introspects declared methods sees the override.

@MapConstructor

No Foo(Map) constructor visible — Java code calling new Foo(someMap) fails to compile even though it works at runtime.

Foo(Map) is declared. When noArg = true, an additional no-arg constructor is also emitted (subject to the same "skip when no properties" guard the runtime applies — empty @Immutable classes do not gain a no-arg in the stub).

@EqualsAndHashCode

Same shape as @ToString: equals(Object) and hashCode() are inherited from Object only, so simple call sites work, but super.equals / super.hashCode chaining and tooling that distinguishes declared from inherited methods see no override.

Both methods are declared on the class with placeholder bodies; the full transform replaces them with the real bodies via the metadata-key handoff. Java subclass overrides chain correctly. The metadata handoff treats stubber-tagged methods as "not yet generated" so the underscore-prefixed fallback machinery (_equals / _hashCode for genuine user-written overrides) remains intact.

@Singleton

Foo.getInstance() is not visible on the stub — Java code calling the standard singleton accessor fails to compile even though the runtime exposes it.

Public static getInstance() (or getXxx() for a custom property = "xxx") is declared with a placeholder body. The public static INSTANCE field is intentionally not stubbed for the spike — see `SingletonASTStubber’s javadoc for rationale.

@IndexedProperty

Indexed accessors getXxx(int) / setXxx(int, T) are not visible on the stub — Java code using indexed-property idioms fails to compile even though the runtime supports them.

Both indexed accessors are declared with placeholder bodies; component types are derived from array element / list generic at CONVERSION. Setter emission is suppressed when the enclosing class carries @Immutable / @ImmutableBase / @KnownImmutable (class-level proxy for the runtime’s per-field IMMUTABLE_BREADCRUMB check, which isn’t set yet at CONVERSION).

@ExternalizeMethods

Class is not declared as Externalizable; writeExternal / readExternal are not visible. Java code that statically requires the class as Externalizable (or calls those methods directly) fails to compile.

Externalizable is on the implements clause; both methods are declared with placeholder bodies and the proper throws IOException on writeExternal. The full transform replaces the bodies with the real field-by-field write/read.

@Final

Class-level final-ness is not visible in the stub. Java code can declare class JavaSub extends ImmutableThing against the stub even though the runtime class is final — compiles in joint compilation, breaks immutability invariants at runtime.

final class in the stub. Smallest possible stubber: one OR ACC_FINAL per class / field / method target. The stubber skips interfaces (including @interface annotation types) where final-on-abstract is illegal. The full transform’s setter is idempotent so no metadata-key handoff is needed.

@Bindable

None of add/removePropertyChangeListener, firePropertyChange, or getPropertyChangeListeners are visible. Java code attaching a PropertyChangeListener fails to compile.

All seven JavaBeans property-change methods are declared with placeholder bodies; the full transform discards the placeholders, installs the property-change-support field, and adds the real bodies.

@Vetoable

Symmetric to @Bindable: the seven Vetoable-listener methods — including fireVetoableChange with its throws PropertyVetoException — are not visible to Java consumers.

All seven methods are declared, with the throws clause on fireVetoableChange rendered into the stub.

@ListenerList

Per-field addXxxListener, removeXxxListener, getXxxListeners, and fireYyy (one per public listener method) are not visible. Java code registering listeners or firing events fails to compile.

All listener-management methods plus per-listener-method fireYyy overloads are declared, with parameter types derived from the field’s generic type at CONVERSION. Same-unit Groovy listener interfaces with their own transform-added methods fall under the same-unit cross-class limit (see Excluded and deferred features); classpath listeners (e.g. java.beans.PropertyChangeListener, java.awt.event.ActionListener) work fully.

@AutoClone

Object.clone() is protected, so Java code calling foo.clone() against the stub fails to compile even though the runtime exposes a public covariant override.

Cloneable on the implements clause; public covariant Foo clone() throws CloneNotSupportedException. Behaviour is identical for all four styles (CLONE / COPY_CONSTRUCTOR / SERIALIZATION / SIMPLE) — they differ only in body, which is irrelevant for stubs.

@NamedVariant

The Map-arg variant of an annotated method or constructor is not visible. Java callers wanting the named-arg form (e.g. Calc.makeSense(args) where args is a Map) fail to compile even though the runtime exposes the variant. Also covers @TupleConstructor(namedVariant = true), which uses the same createMapVariant helper.

Map-arg variant declared alongside the user-written original for both methods and constructors. Mixed @NamedParam/@NamedDelegate configurations produce a richer runtime signature that the stubber emits as a strict subset (single Map-arg form). The typical direct-use case has its target visible at CONVERSION; an early plan to defer based on hypothetical transform-added targets turned out to be unnecessary (see the deferral-framing entry under Recurring patterns).

@Builder

Foo.builder(), the inner builder class with its fluent setters, and the build() method are not visible. Java callers wanting Foo.builder().name("a").age(7).build() fail to compile.

For DefaultStrategy (the default): inner builder class declared inline in the stub with placeholder fluent setters and build(), plus a static Foo.builder() factory on the buildee. For SimpleStrategy with a non-default prefix (e.g., prefix = "with"): chained Foo prefixName(T) setters on the buildee. Default-prefix SimpleStrategy (where the chained setter would collide with the auto-generated void setter for the property), InitializerStrategy, ExternalStrategy, method/constructor targets, and forClass are documented limitations rather than deferrals.

@AutoImplement

None of the abstract methods reachable through the supertype / interface graph are implemented on the stub. Java callers reaching for those methods get a stub-time compile error even though the runtime supplies default-value (or exception-throwing) implementations.

Each abstract reachable through the same walk the full transform uses (classpath supertypes, same-unit Groovy abstract classes, and same-unit Groovy traits with source-declared abstracts) is declared on the stub with a default-value placeholder body. Empirically wider than initially analysed: same-unit trait abstracts are visible at CONVERSION because the trait’s source-declared abstract methods sit on the trait ClassNode from CONVERSION on. The narrow residual is abstracts that another transform adds (via cNode.addMethod) at SEMANTIC_ANALYSIS+ on a same-unit supertype — no current transform does this.

@InheritConstructors

None of the super class’s constructors are visible on the subclass stub. Java callers writing new MyEx("boom") against a @InheritConstructors-annotated RuntimeException subclass fail to compile.

Super class’s non-private declared constructors copied onto the subclass stub with full generic substitution and a super(args) placeholder body. Recursive pre-processing handles chained @InheritConstructors (Sub → Mid → Base) within the stubber pass. Boundary: when the super class is in the same compilation unit and its constructors come from another CONVERSION-phase stubber (typically @TupleConstructor), visibility depends on within-phase ordering — super-declared-first works, sub-declared-first leaves those tuple-derived signatures off the stub. Locked in by an explicit subset assertion in the test.

@Delegate

None of the delegate type’s public methods or properties are visible on the owner stub — Java callers writing event.before(otherDate) against an @Delegate Date when field fail to compile, and the owner does not appear to implement the delegate’s interfaces.

Field-target and method-target both produce a stub that mirrors the runtime: every public delegate method is declared on the owner with a default-value placeholder body, property accessors honour the same extractAccessorInfo clash-avoidance the runtime uses, the array getLength() shortcut is included where applicable, and (when interfaces=true) the delegate’s interface graph is unioned into the owner’s implements clause. Filter mirroring is critical to the subset invariant: excludes, excludeTypes, includes, includeTypes, deprecated, and allNames reach the stubber via shared helpers extracted from the full transform, so any method the runtime won’t add also won’t appear on the stub. Generic owner with generic-typed delegate (class Bag<T> { @Delegate List<T> items }) propagates the type parameter through delegated method signatures correctly.

@RecordType

For native records (target >= JDK16, mode != EMULATE), javac does not see record syntax — the stub renders a plain class with default bean accessors and a missing canonical constructor. For emulated records, the stub additionally lacks the Groovy-specific record convenience methods (getAt(int), toList(), toMap(), size(), opt-in copyWith(Map) and components()) and exposes bean-style getters / auto-synthesised setters that the runtime does not have.

Two complementary mechanisms cover the two modes. Native records are handled by a stub-side back-channel (GROOVY-11974): JavaStubGenerator calls RecordTypeASTTransformation.wouldBeNativeRecord(…​) at CONVERSION and emits record Foo(…​) syntax, leaving javac to synthesise the canonical constructor and component accessors. Emulated records are handled by a Shape C stubber on @RecordBase that flips property modifiers / getter names so the stub generator’s Verifier sub-pass emits componentName() accessors and skips setters, and emits stub placeholders for the convenience methods using the same shouldAdd* predicates the runtime transform uses (refactored as package-private statics on RecordTypeASTTransformation, mirroring the filter-sharing approach taken for @Delegate).

In every case the runtime output is unchanged — these are stub-fidelity fixes, not behavioural changes.

Two compositional cases — @Canonical and @Immutable — are covered by dedicated joint-compilation tests in the spike that exercise the @AnnotationCollector expansion path. They need no new stubbers; they confirm that once the constituent transforms are joint-compile-aware, meta-annotations compose with Shape C without extra framework support.

Recurring patterns and implementation gotchas

Across the spiked transforms a small set of patterns emerged that future Shape C authors will want to know about:

  • Subset-via-class-annotation heuristic. When a runtime guard depends on later-phase metadata (e.g. @IndexedProperty’s setter-suppression checks per-field `IMMUTABLE_BREADCRUMB, which is only set at CANONICALIZATION), the stubber can sometimes use a CONVERSION-visible proxy — most often the presence of an Immutable-family annotation (@Immutable, @ImmutableBase, @KnownImmutable) on the enclosing class. The proxy isn’t always exact; less common configurations may produce a stub that over-claims. Document the approximation in javadoc.

  • "Skip when no properties" guard for compositional cases. When a transform emits something conditional on annotation flags (e.g. @MapConstructor(noArg = true), @TupleConstructor(defaults = true)), the stubber must mirror whatever runtime guards also depend on AST state. Without this, empty classes that compose the transform via @Immutable / @Canonical end up with stub members the runtime won’t have. Both MapConstructorASTStubber and TupleConstructorASTStubber needed a "skip when no directly-declared properties" check to pass the existing ImmutableWithJointCompilationStubTest regression.

  • Interface additions are naturally idempotent. @Sortable (Comparable<Self>) and @ExternalizeMethods (Externalizable) both add an interface to the implements clause. The stubber adds it; the full transform’s idempotent addInterface call (or its own implementsInterface guard) tolerates the prior addition without metadata-key handoff. Other "marker interface" contributions can follow the same lighter pattern.

  • Method exception lists must be explicit empty arrays. Several full transforms pass null for the exceptions parameter to addGeneratedMethod, but JavaStubGenerator.printExceptions NPEs on null. Stubber-emitted methods (which DO go through the stub generator) must use ClassNode.EMPTY_ARRAY instead.

  • Shared helpers are ordinary refactoring. Where the stubber and the full transform both want the same idempotent setup (interface check + method-add), extract a static package-protected helper on the full transform and call it from both pieces. SortableASTTransformation.addComparableSurface is the canonical example; TupleConstructorASTTransformation.resolveDefaultsMode is a smaller variant covering annotation-member resolution. Where the duplication isn’t real (each piece’s main loop is genuinely transform-specific), no helper is needed — don’t force it. The spike also extracts a small framework-level utility that any stubber can use for the recurring add-and-tag pattern, alongside predicates for "is this a stubber placeholder" and "clear the placeholder tag".

  • Bulk method removal needs removeMethod, not removeIf. ClassNode keeps both a methodsList and a parallel name-to-methods map. getMethods().removeIf(…​) mutates only the list; the map keeps the stale entries and the next addGeneratedMethod perceives a duplicate ("Repetitive method name/signature" verifier error). Stubber-tag cleanup that needs to remove methods (rather than replace bodies in place) must iterate and call classNode.removeMethod(…​) per node. Constructor cleanup is unaffected: constructors live in only one list.

  • Compositional ordering and "honour user-declared" guards. When two stubbers can target the same kind of member on the same class (the canonical case is @TupleConstructor and @MapConstructor combined under @Immutable), a guard like "skip if the user already declared a constructor" must filter out other stubbers' placeholders. Otherwise whichever stubber fires second silently no-ops. Single-transform tests don’t catch this — only compositional cases do; the spike’s @Canonical and @Immutable tests are the smallest reliable triggers.

  • Filter mirroring prevents stub-superset divergence. For most spiked transforms the only failure mode is "stub is a strict subset of runtime" — Java callers may get a stub-time compile error for things that run fine, but never the reverse. Transforms whose set of emitted members depends on user-supplied annotation attributes — @Delegate’s `includes / excludes / includeTypes / excludeTypes / deprecated / allNames, or @RecordType’s {@RecordOptions`} attributes (getAt, toList, toMap, size, copyWith, components) — introduce the opposite failure mode: if the stubber forgets to mirror the gating logic, the stub exposes methods the full transform won’t add, Java code compiles, then runtime fails with MissingMethodException. The fix is to share the enumeration / predicate helpers between stubber and full transform rather than reproduce them. Both spikes follow the same pattern: promote the relevant private statics on the full transform to package-private and call them from the stubber. DelegateASTTransformation shares collectMethods, filterMethods, extractAccessorInfo, and friends with DelegateASTStubber. RecordTypeASTTransformation shares shouldAddGetAt / shouldAddToList / shouldAddToMap / shouldAddSize / shouldAddCopyWith / shouldAddComponents with RecordBaseASTStubber. Same source of truth, same answers.

  • Inner-class stubbing needs manual module wiring. The natural API (module.addClass(innerClass)) registers the class globally in the CompileUnit’s by-name map. The full transform’s `addGeneratedInnerClass then trips a "duplicate class definition" error when it tries to register its own (untagged) replacement. The workaround is to add to module.getClasses() and call setModule manually, but skip the CompileUnit registration — the stub generator finds the inner class via the outer’s innerClasses list and renders it inline. The full transform’s cleanup then removes the stubber-tagged entry from the module before addGeneratedInnerClass runs. The spike’s @Builder with DefaultStrategy is the case that surfaces this.

  • Deferral framing — transform-on-transform stacking is rare in practice. It’s tempting to defer a transform "because another transform might add the target it operates on, and that target won’t be visible at CONVERSION." The spike showed this framing is often overly cautious: real users compose via parameters / properties / interfaces on hand-written members, which are visible at CONVERSION. @NamedVariant was initially deferred on this basis before being covered once the typical direct-use case was examined; @AutoImplement was expected to lose coverage on same-unit Groovy traits and turned out to keep it. When sketching a deferral, validate the failure case against an actual user pattern rather than a hypothetical compositional scenario before accepting the deferral.

Transforms that need no stubber

A small number of transforms produce no stub-visible output, so they do not need a Shape C piece. Listed here for explicit "considered, no work needed" coverage rather than silent absence.

Transform Why no stubber is needed

@AutoFinal

Sets {Modifier.FINAL} on each method / constructor / closure parameter. The stub generator does not emit parameter modifiers (JavaStubGenerator.printParams writes only annotations, type, and name), so the change is invisible to Java consumers regardless of when it runs. Pure internal-discipline annotation; if the stub generator is ever enhanced to emit parameter modifiers, a stubber mirroring the full transform’s parameter-walk would be the minimal fix.

@ImmutableBase

Sets fields to private final, adds serialVersionUID, marks fields with the per-field IMMUTABLE_BREADCRUMB metadata key, and wires the {ImmutablePropertyHandler}. All of these are runtime semantics: defensive copies inside generated constructor bodies, immutable-property handler integration, breadcrumbs that other transforms (e.g. @IndexedProperty) consult later. The Java-visible API — constructors, getters, no setters, equals/hashCode/toString — is fully covered by the constituent transforms (@TupleConstructor, @MapConstructor, @ToString, @EqualsAndHashCode, @Final). The composing transforms cover @ImmutableBase’s contribution; nothing more is visible to `javac.

What a stub contribution may emit

A stub-contribution call may mutate the target class node in any of these ways:

  • Add new constructors, with parameters, exceptions, generics, and parameter annotations populated. The body is a placeholder (super(…​) if a super constructor must be invoked, otherwise an empty block).

  • Add new methods, with the same signature fidelity as constructors. The body is a placeholder consistent with the declared return type (return null;, return 0;, return false;, or empty for void). The stub printer already understands such placeholders.

  • Add new fields, with declared type, modifiers, and any constant initializer that is a literal expression. Non-literal initializers are deferred to the authoritative pass and rendered as default values in the stub.

  • Add new declared interfaces (addInterface(…​)).

  • Add a new declared super class (setSuperClass(…​)), with the usual caveat that changing the super class is a global decision and may conflict with other transforms.

  • Add annotations on any of the above, including the contributing transform’s own marker annotations.

  • Flip modifiers on existing user-declared members where the change has no runtime effect — for example marking a PropertyNode final to suppress the Verifier sub-pass’s setter generation when the authoritative pass will replace the property entirely (the spike’s @Lazy case).

A stub contribution may not:

  • Change runtime semantics of user-declared members — bodies of user-written methods, types of declared fields, parameter lists of user-written constructors, and similar. These are owned by the source author and the authoritative pass; the stub pass must produce a class node whose runtime is unchanged compared to a pure-Groovy compilation of the same source.

  • Add behaviour that depends on inferred types from later phases (e.g. types derived by StaticTypeCheckingVisitor). The transform must declare types from information available at CONVERSION.

  • Inspect transform-added members on other classes in the same compilation unit. Same-unit Groovy sources have only their source- declared members visible at CONVERSION; their own transforms haven’t run yet. (See Excluded and deferred features.)

Skeleton members and the authoritative pass

Every member added by a stub contribution is tagged with a metadata flag identifying it as a skeleton awaiting completion. The authoritative pass — whether that’s the same transform running at its declared phase (Shapes A and B), or the sibling transform at its declared phase (Shape C) — uses the flag to decide between paths per member:

  • If a matching skeleton already exists on the class node, the authoritative pass fills in the body of that node (and any further annotations or modifiers it intends to add) rather than creating a new node. The skeleton flag is cleared.

  • If no matching skeleton exists, the authoritative pass creates the member as it does today.

  • If the authoritative pass supersedes the skeleton entirely (as in the spike’s @TupleConstructor, where the full transform discards any stubber-tagged constructors before generating its own), the pass removes the skeletons before its main work.

This contract makes the authoritative pass robust against the stub pass having been run, having been partially run, or not having been run at all (e.g. for a non-joint-compilation build that goes straight through CONVERSION without touching the stub op chain). A transform written for the joint-compile-aware contract continues to work in all build modes.

The matching predicate is "same kind, same name, structurally equal parameter list, same declaring class". Generic substitution is applied before comparison so that a skeleton declared with type variables and a member emitted by the authoritative pass with the same variables match.

Ordering of contributions

When multiple joint-compile-aware transforms apply to the same target, contributions run in priority order using the existing TransformWithPriority mechanism. Contributions are deterministic and idempotent: rerunning them on a class node already containing their previous output is a no-op (matching the body-pass tolerance above).

A contribution may rely on output of an earlier contribution within the same class. For example, @EqualsAndHashCode may rely on @TupleConstructor having already added a constructor whose parameter list it does not need to introspect (it works from fields), so order rarely matters; but where it does, priority is the mechanism.

A contribution may not rely on output of contributions on a different class in the same unit. That ordering is undefined within a single CONVERSION pass.

Interaction with @AnnotationCollector

@AnnotationCollector aliases are expanded in place on the AST by the transform-collector visitor at CONVERSION, before stub contribution runs. A class declared @MyAlias class C { …​ } whose alias expands to @TupleConstructor + @ToString reaches the stub- contribution step with the constituent annotations on the class node, and each constituent transform’s stub contribution fires normally.

No additional plumbing is required for aliases. This includes custom @AnnotationCollector processors: they remain in charge of which annotations are produced, and the produced annotations participate in stub contribution like any other.

Interaction with the Verifier sub-pass

The stub generator’s embedded Verifier sub-pass continues to run during stub printing. It synthesises:

  • Property accessors for declared `PropertyNode`s.

  • getMetaClass() / setMetaClass(MetaClass) for the implicit GroovyObject interface.

  • Default constructors where required by the verifier.

The Verifier sub-pass operates on whatever class node state exists at the moment it runs, which now includes any stub contributions. A property added by a stub contribution gets its accessors synthesised by Verifier automatically; a constructor added by a contribution is rendered as-is by the stub printer.

The interaction is additive: contributions never disable Verifier, and Verifier does not inspect the skeleton metadata.

Existing back-channels

The stub-side back-channels for @PackageScope, traits, and now native records (via GROOVY-11974) remain in place. They predate or sit alongside this proposal and target output that is not a new member but a modification to how an existing class node is rendered (modifier flags, class-vs-interface-vs-record emission, header syntax). Those are well-suited to stub-side handling and there is no benefit to migrating them.

The native-records case is worth highlighting because it overlaps with @RecordType’s migration. The stub generator now renders `record Foo(…​) syntax when RecordTypeASTTransformation.wouldBeNativeRecord(…​) returns true at Phases.CONVERSION; javac then synthesises the canonical constructor and component accessors itself. No Shape C stubber is required for the native-records path — the back-channel pattern is the right shape because the change is about header syntax rather than member injection. Emulated records (target < JDK16 or explicit mode = EMULATE) still render as plain classes; the record-shaped surface (component accessors, no setters, canonical constructor) is provided on those by the Shape C stubber on @RecordBase working in concert with the existing @TupleConstructor stubber.

Future modifier-only or rendering-only transforms should follow the same pattern: a small stub-side hook in JavaStubGenerator reading the annotation directly. Member-injection transforms — the cases this proposal targets — should use the joint-compile-aware mechanism instead.

Compilation

Phase op wiring

JavaAwareCompilationUnit and JavaStubCompilationUnit register a new phase operation at Phases.CONVERSION, ordered between the existing transform-collector op and the existing stub-generation op. The new op invokes any AST transformations registered for the CONVERSION phase on each class node.

The ordering rule for joint compilation is, per source unit, per class:

  1. JavaAwareResolveVisitor

  2. ASTTransformationCollectorCodeVisitor (collects + expands aliases)

  3. new: CONVERSION-phase transform invocation

  4. JavaStubGenerator.generateClass(…​)

The spike implements step 3 by reusing the existing ASTTransformationVisitor machinery — a public factory ASTTransformationVisitor.invocationOperation(phase, context) returns an IPrimaryClassNodeOperation that walks the class with the same dispatch logic as the SEMANTIC_ANALYSIS+ phases. No new visitor class is required.

For non-joint-compilation builds (CompilationUnit rather than JavaAwareCompilationUnit), step 3 is absent. Shape A and Shape B transforms simply do their work at the declared phase as today. Shape C transforms see only the second (authoritative) piece run; the CONVERSION-phase piece is silently inert because no invoker fires it. This is by design: each shape’s later piece must remain authoritative for pure-Groovy compilation regardless of how the early piece is wired.

Required framework adjustments for Shape C

The spike validated one small framework change:

  • The existing guard in ASTTransformationCollectorCodeVisitor.verifyAndAddTransform rejected any local transform with phase < SEMANTIC_ANALYSIS. It is relaxed to phase < CONVERSION. CONVERSION-phase local transforms are now legal; they fire only in joint compilation (where step 3 above invokes them) and are inert in pure-Groovy compilation (no invoker exists for CONVERSION in standard CompilationUnit).

Using Shape C annotations through ASTTransformationCustomizer

ASTTransformationCustomizer is unchanged in scope: a single instance still represents a single transform class. With Shape C, an annotation like @TupleConstructor lists two transform classes in its @GroovyASTTransformationClass value, so users wanting to apply such an annotation through the customizer obtain one customizer per transform via the ASTTransformationCustomizer.forAnnotation(…​) factory:

configuration.addCompilationCustomizers(
    *ASTTransformationCustomizer.forAnnotation(TupleConstructor)
)

The factory expands @AnnotationCollector aliases as well (e.g. @AutoExternalize), so the same call works for meta-annotations whose constituents have been migrated to Shape C. Direct construction (new ASTTransformationCustomizer(SomeShapeCAnnotation)) now throws with a pointer to forAnnotation; this is the only customizer-side behaviour change attributable to Shape C.

Resolver state at CONVERSION

The resolver at CONVERSION resolves signature-level information: class headers, super, interfaces, field types, parameter / return types, annotation types and their constant values. It deliberately skips method-body containers. A stub contribution may rely on signature-level resolution but must not require body-level resolution.

For same-unit Groovy classes referenced from the contributing class, this means:

  • Their type names resolve.

  • Their declared fields, properties, source-written methods, and constructors are visible.

  • Members added by their own transforms are not yet visible — those transforms haven’t run.

A contribution that needs to enumerate members of another same-unit Groovy class is therefore limited to source-declared members. For classpath types (already-compiled Java classes, JDK types, dependencies), the full class is available via DecompiledClassNode.

Generic substitution

Generics are populated on class nodes at parse / convert time: declared type variables, bounds, and parameterizations of declared field / parameter / return types are all in the AST when stub contribution runs. A contribution emitting a parameterized signature uses the same generic-utilities helpers (GenericsUtils.correctToGenericsSpec, etc.) that the stub generator uses for its own rendering; cross-substitution between a class’s type variables and an inherited supertype’s type variables is supported.

For a contribution that emits members whose signatures depend on a classpath supertype (e.g. an @AutoImplement-style filling of abstract methods), the supertype’s resolved members and their generic specialisations are available. For a same-unit Groovy supertype, only source-declared members are available; transform- added abstract methods on the supertype are not yet seen.

Authoritative-pass replacement contract

The authoritative pass — the same transform’s normal-phase visit in Shapes A and B, or the sibling transform’s visit in Shape C — is updated for each joint-compile-aware transform to:

  1. Locate any skeleton members on the class node (via the metadata flag) that match what the pass would have added.

  2. For each match, either replace the placeholder body and clear the flag, or — when the pass produces output that supersedes the skeleton entirely — discard the skeleton before doing its work (the spike’s @TupleConstructor takes this approach).

  3. For non-matches (stub pass not run, or new members the contribution did not anticipate), behave as today.

This update is local to each transform. The authoritative pass remains the canonical specification of what the transform produces; the stub contribution is constrained to be a subset (signature-only plus modifier nudges, no body content) of the authoritative pass.

Compatibility

Backwards compatibility

  • Transforms that don’t opt in are unchanged. Their dispatch phase, ordering, AST visit signature, and bytecode output are identical to today. No transform is required to opt in.

  • Stubs for classes whose annotations all come from non-opt-in transforms are unchanged. Byte-for-byte equivalence is preserved for the existing standard library transforms until they migrate.

  • Stubs for classes whose annotations include opt-in transforms gain members. The added members reflect the transform’s runtime output. This is the load-bearing change of the proposal.

  • Programs that compiled under joint compilation today continue to compile and produce identical bytecode. The new members are additive; nothing is renamed, retyped, or removed.

  • Programs that previously failed to compile under joint compilation due to missing stub members now compile. This is the intended observable effect.

Forward compatibility

  • The mechanism is opt-in per transform. Future standard-library transforms can be joint-compile-aware from inception. Third-party transforms can opt in independently of any Groovy release that ships standard-library migrations.

  • The choice between Shape A (annotation attribute) and Shape B (interface) is fixed at implementation time and is a property of the framework, not of any transform’s public contract.

  • The skeleton-metadata key used for body-pass replacement is an internal contract between the framework and individual transforms. Third-party transforms using the framework’s helpers do not see it.

Tooling implications

  • IDEs that consume Groovy stubs (IntelliJ, Eclipse) gain visibility into the migrated transforms' output without per-transform IDE support code.

  • Build tools that cache stubs (Gradle’s compileGroovy / compileJava task graph) need to invalidate the cached stubs after any change that affects an opt-in contribution. The existing invalidation already handles annotation-value changes; the proposal does not add new triggers.

  • Documentation tools (groovydoc, generated Javadoc against the stubs) reflect the stub contents. Migrated transforms' members appear in generated documentation.

Excluded and deferred features

Feature Status Rationale

Same-unit cross-class transitive transform output

Deferred

If class A’s contribution depends on members that another same-unit Groovy class B’s transforms will add, A’s contribution sees only B’s source-declared members. Solving this requires a fixed-point scheduling model where contributions run, the AST is re-examined, and the cycle repeats until stable. That is a substantial architectural change beyond the scope of this proposal.

Type inference results from later phases

Not planned

StaticTypeCheckingVisitor runs at INSTRUCTION_SELECTION, far after CONVERSION. Stub contributions cannot use inferred types. In practice this is not a barrier: declared types from class generics, fields, and supertypes are sufficient for every transform the spike covers.

Cross-transform annotation propagation in stubs

Deferred

If transform A’s contribution adds method m, and transform B (running later, at SEMANTIC_ANALYSIS+) adds an annotation to m, the runtime class carries the annotation but the stub does not. Annotations contributed during the stub pass are in the stub. Pulling cross-transform annotations into the stub would require either re-emitting stubs after each later phase (expensive) or promoting cross-transform interactions into the stub pass (loses the separation that makes the proposal feasible).

Body fidelity in stubs

Not planned

Stub bodies are placeholders by design. Joint compilation never needs body content for javac; only signatures matter. Contributing transforms produce placeholder bodies in the same style.

Re-running the entire transform pipeline at CONVERSION

Not planned

Some transforms have legitimate dependencies on later-phase information (type inference, full body resolution); running the whole pipeline twice would force every transform to be re-entrant and would not improve stub fidelity beyond what targeted opt-in contributions already achieve.

Migrating the @PackageScope and trait stub-side back-channels to the new mechanism

Not planned

The existing back-channels target rendering decisions (modifier flags, class-vs-interface emission) rather than member injection. Stub-side handling is a better fit for those cases. The new mechanism is for member injection only.

Update history

1 (2026-04-28) Initial proposal: motivation, design principles, Shapes A and B, migration tiers
2 (2026-04-29) Added Shape C (split-transform model) and the working spike (https://github.com/apache/groovy/pull/2500) covering @Sortable, @TupleConstructor, @Lazy, @ToString; before/after spike-results table; framework-adjustments subsection (rail relaxation + customizer filter)
3 (2026-04-29) Spike extended to nine transforms with @MapConstructor, @EqualsAndHashCode, @Singleton, @IndexedProperty, @ExternalizeMethods; added "Recurring patterns and implementation gotchas" subsection; updated migration tiers to reflect the broader spike validation
4 (2026-04-29) Tier 1 complete: spike adds @Final, @Bindable, @Vetoable, @ListenerList; compositional @Canonical and @Immutable tests; "Transforms that need no stubber" subsection; two new gotchas (parallel-map method removal, compositional ordering guards)
5 (2026-04-29) Tier 2 covered: spike adds @AutoClone and @Builder (DefaultStrategy + non-default-prefix SimpleStrategy); @NamedVariant promoted from Tier 3 to Tier 1; new gotcha (inner-class stubbing needs manual module wiring)
6 (2026-04-30) Tier 2 complete and Tier 3 collapsed: spike adds @AutoImplement, @InheritConstructors, @Delegate (refactor-and-share with full transform); new gotcha (filter mirroring prevents stub-superset divergence)
7 (2026-04-30) Reconciled with master and @RecordType validated: GROOVY-11974 covers native-record stubs via a stub-side back-channel; emulated records covered by a new RecordBaseASTStubber that flips property getter names + finalness so the stub Verifier sub-pass emits componentName() accessors and skips setters, and emits stub placeholders for the Groovy-specific record convenience methods (getAt, toList, toMap, size, opt-in copyWith, components) gated by the same shouldAdd* predicates the runtime uses (refactor-and-share, like @Delegate); GROOVY-11973’s ASTTransformationCustomizer.forAnnotation(…​) is the customizer path for multi-class annotations, removing the customizer-filter framework adjustment claimed in earlier revisions; with every covered transform now spike-validated, the migration tier section was retired — unique per-transform notes folded into the spike-results "After" column, "Transforms that need no stubber" relocated under spike-results, and a new "deferral framing" gotcha captures the lesson from `@NamedVariant’s initial over-cautious deferral