GEP-21
|
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:
-
Groovy parses and converts every source.
-
At
Phases.CONVERSION, Groovy emits Java stubs — empty-bodied Java files containing the public API surface of each Groovy class. -
javaccompiles the Java sources together with the stubs. -
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
Verifiersub-pass that synthesises property accessors and thegroovy.lang.GroovyObjectglue fromPropertyNodeentries declared in source. PlainString namebecomesgetName()/setName(String)in the stub. -
JavaStubGeneratorreads the@PackageScopeannotation directly to adjust class / field / constructor / method modifiers in the stub even thoughPackageScopeASTTransformationitself runs atSEMANTIC_ANALYSIS. -
JavaStubGeneratorrecognises traits viaTraits.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.CONVERSIONop chain alongside the resolver and the transform-collector visitor. Transform dispatch for declared phases (SEMANTIC_ANALYSIS,CANONICALIZATION, …) is unchanged. -
Reuse existing machinery.
@AnnotationCollectoraliases are already expanded in place atCONVERSIONby 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:
-
Resolution —
JavaAwareResolveVisitorresolves type names in class headers, super, interfaces, field types, parameter / return types, and annotation types. Method bodies are deliberately not walked at this point. -
Annotation collection and alias expansion —
ASTTransformationCollectorCodeVisitorfinds every transform-bearing annotation on the AST, expands@AnnotationCollectoraliases in place, and records which transforms apply at which phases. -
Stub generation —
JavaStubGeneratorwalks each non-private top-level class node and writes a.javastub, running an embeddedVerifiersub-pass to synthesise property accessors and theGroovyObjectglue.
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:
-
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
JavaStubGeneratorwill subsequently render: adding members (interfaces, methods, constructors, fields) with placeholder bodies, flipping modifiers on existing members so the embeddedVerifiersub-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
addComparableSurfaceshared betweenSortableASTStubberandSortableASTTransformation). 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 (
@GroovyASTTransformationClasslists, 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) |
|---|---|---|
|
Class does not declare |
|
|
No tuple constructor visible — only the implicit no-arg. Java code
writing |
For |
|
Stub has both |
Stub has only |
|
|
|
|
No |
|
|
Same shape as |
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 ( |
|
|
Public static |
|
Indexed accessors |
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 |
|
Class is not declared as |
|
|
Class-level final-ness is not visible in the stub. Java code can
declare |
|
|
None of |
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. |
|
Symmetric to |
All seven methods are declared, with the |
|
Per-field |
All listener-management methods plus per-listener-method |
|
|
|
|
The Map-arg variant of an annotated method or constructor is not
visible. Java callers wanting the named-arg form (e.g.
|
Map-arg variant declared alongside the user-written original for
both methods and constructors. Mixed
|
|
|
For |
|
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 |
|
None of the super class’s constructors are visible on the subclass
stub. Java callers writing |
Super class’s non-private declared constructors copied onto the
subclass stub with full generic substitution and a |
|
None of the delegate type’s public methods or properties are visible
on the owner stub — Java callers writing |
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
|
|
For native records (target |
Two complementary mechanisms cover the two modes. Native records are
handled by a stub-side back-channel
(GROOVY-11974):
|
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/@Canonicalend up with stub members the runtime won’t have. BothMapConstructorASTStubberandTupleConstructorASTStubberneeded a "skip when no directly-declared properties" check to pass the existingImmutableWithJointCompilationStubTestregression. -
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 idempotentaddInterfacecall (or its ownimplementsInterfaceguard) 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
nullfor the exceptions parameter toaddGeneratedMethod, butJavaStubGenerator.printExceptionsNPEs on null. Stubber-emitted methods (which DO go through the stub generator) must useClassNode.EMPTY_ARRAYinstead. -
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.addComparableSurfaceis the canonical example;TupleConstructorASTTransformation.resolveDefaultsModeis 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, notremoveIf.ClassNodekeeps both amethodsListand a parallel name-to-methods map.getMethods().removeIf(…)mutates only the list; the map keeps the stale entries and the nextaddGeneratedMethodperceives 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 callclassNode.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
@TupleConstructorand@MapConstructorcombined 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@Canonicaland@Immutabletests 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 withMissingMethodException. 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.DelegateASTTransformationsharescollectMethods,filterMethods,extractAccessorInfo, and friends withDelegateASTStubber.RecordTypeASTTransformationsharesshouldAddGetAt/shouldAddToList/shouldAddToMap/shouldAddSize/shouldAddCopyWith/shouldAddComponentswithRecordBaseASTStubber. Same source of truth, same answers. -
Inner-class stubbing needs manual module wiring. The natural API (
module.addClass(innerClass)) registers the class globally in theCompileUnit’s by-name map. The full transform’s `addGeneratedInnerClassthen trips a "duplicate class definition" error when it tries to register its own (untagged) replacement. The workaround is to add tomodule.getClasses()and callsetModulemanually, but skip theCompileUnitregistration — the stub generator finds the inner class via the outer’sinnerClasseslist and renders it inline. The full transform’s cleanup then removes the stubber-tagged entry from the module beforeaddGeneratedInnerClassruns. The spike’s@BuilderwithDefaultStrategyis 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.
@NamedVariantwas initially deferred on this basis before being covered once the typical direct-use case was examined;@AutoImplementwas 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 |
|---|---|
|
Sets { |
|
Sets fields to |
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 forvoid). 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
PropertyNodefinal to suppress theVerifiersub-pass’s setter generation when the authoritative pass will replace the property entirely (the spike’s@Lazycase).
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 atCONVERSION. -
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 implicitGroovyObjectinterface. -
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:
-
JavaAwareResolveVisitor -
ASTTransformationCollectorCodeVisitor(collects + expands aliases) -
new:
CONVERSION-phase transform invocation -
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.verifyAndAddTransformrejected any local transform withphase < SEMANTIC_ANALYSIS. It is relaxed tophase < 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 forCONVERSIONin standardCompilationUnit).
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:
-
Locate any skeleton members on the class node (via the metadata flag) that match what the pass would have added.
-
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
@TupleConstructortakes this approach). -
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/compileJavatask 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 |
|
Cross-transform annotation propagation in stubs |
Deferred |
If transform A’s contribution adds method |
Body fidelity in stubs |
Not planned |
Stub bodies are placeholders by design. Joint compilation never
needs body content for |
Re-running the entire transform pipeline at |
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 |
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. |
References
-
GEP-21 spike PR — Shape C implementation covering every transform whose runtime output has stub-visible signatures [Original PR]
-
ASTTransformationCollectorCodeVisitor—@AnnotationCollectoralias expansion at CONVERSION -
ASTTransformationVisitor— phase-dispatch wiring for local transforms -
Traits — example of an existing stub-side back-channel for non-injection rendering decisions
-
Existing split-transform precedents in the codebase:
SealedASTTransformation/SealedCompletionASTTransformation,ExternalizeMethodsASTTransformation/ExternalizeVerifierASTTransformation. Both pairs run afterCONVERSION; Shape C extends the same idiom by allowing the first piece atCONVERSION.
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