Skip to content

Latest commit

 

History

History
349 lines (289 loc) · 17.2 KB

File metadata and controls

349 lines (289 loc) · 17.2 KB

Apache Groovy — Compatibility and Stability

Public API in Groovy is covenanted: applications and libraries depend on it across releases, and removing or breaking it has costs we don't always see locally. This document is the contributor-facing reference for what is public, what is internal, what counts as a breaking change, and how the build helps us notice when one slips through.

If you're orienting in the codebase generally, start with ARCHITECTURE.md. For build and submission mechanics, see CONTRIBUTING.md. The user-facing version-numbering scheme (SemVer since 2.0.0) is at src/spec/doc/version-scheme.adoc.

Stability tiers

Stability is signalled by package, by annotation, and occasionally by documented convention. The four tiers from most to least stable:

Tier How it's marked Stability commitment
Public API Lives in groovy.*, or in org.apache.groovy.* / org.codehaus.groovy.* without an internal marker Source- and binary-compatible across minor and patch releases. Breaking changes need a major version.
Incubating Annotated @org.apache.groovy.lang.annotation.Incubating, or noted as "incubating" in the feature's prose documentation Reduced stability guarantee — design may still settle, so users opting in accept some risk of change in a minor release. See "Incubating features" below for what this means in practice.
Internal Annotated @groovy.transform.Internal, or in a package whose name contains internal No stability guarantee. Treat as implementation detail, even if technically reachable.
Generated Anything emitted by the build into build/generated/... or repackaged via the groovyjarjar* namespace Not API at all; avoid referring to these from anywhere stable.

org.codehaus.groovy.* is a historical complication: most of it is internal-by-intent but treated as public-by-practice because users have come to depend on it. The safe assumption is that it's public unless explicitly marked @Internal or living in an internal sub-package.

The internal package convention is the one the build actually enforces — see "The binary-compatibility check" below. Existing examples include org.apache.groovy.internal.util.*, org.apache.groovy.internal.metaclass.*, and org.apache.groovy.parser.antlr4.internal.*.

Incubating features

@Incubating reduces the formal stability guarantee — it tells users this surface may still change as the design settles — but project practice is more conservative than the annotation's Javadoc wording suggests. We don't break incubating APIs gratuitously: when a feature has settled into the shape it wants and nothing else forces a change, it stays as-is. @Incubating is the permission slip we use only when the design genuinely has open questions, not a licence for churn.

Some features can't carry the annotation because they aren't expressed as a class or method — a grammar tweak, a few lines added to an existing visitor, a behavioural adjustment scattered across the runtime. In those cases the incubating signal moves to the prose: the feature's section under src/spec/doc/ (or the relevant subproject's src/spec/doc/) says explicitly that the feature is incubating. The reduced guarantee and the project's posture of caution before changing them are the same as for an @Incubating annotation.

A new feature that should be incubating but can't carry the annotation gets the prose marker in the documentation in the same PR as the code change. When the feature graduates, the marker comes out of both places (annotation and prose) at the same time.

What counts as a breaking change

For anything in the Public API tier, all of the following are breaking:

  • Removing a class, interface, method, field, or annotation type.
  • Changing a method's name, return type, parameter list, or thrown checked exceptions.
  • Narrowing visibility (public → protected, protected → package-private).
  • Adding a method to an interface or abstract method to a class without a default implementation.
  • Renaming or moving a package.
  • Changing a class's superclass or removing an implemented interface.
  • Removing or renaming an enum constant.
  • Renaming a service-file key, or removing a previously-published service implementation (see "Service files" below).

These are also breaking, even though japicmp may not catch them:

  • Behavioural changes that users have come to rely on, including MetaClass dispatch, method-resolution order, GDK method semantics, and serialization formats.
  • Changing the bytecode shape that @CompileStatic produces in a way that breaks reflection-based callers.
  • Changing the AST shape produced for a given source construct in a way that breaks third-party AST transformations.
  • Changing the order or timing of compilation phases that user customizers attach to.
  • Breaking Java interoperability — including changes that prevent Java code from calling compiled Groovy classes, that prevent Groovy code from calling existing Java libraries, that change joint-compilation semantics, or that change the shape of generated Java stubs. Seamless Java interop is a covenant of Groovy, not just a feature.

The bar for a breaking change is discussion on the dev list and a major version, not a single PR. If a change might be breaking, the safe default is to assume it is and ask on the list.

Adding new public API

New API is the easier-to-fix mistake — you can deprecate and remove — but it's still costly. Before adding any:

  1. Justify the surface. Is this something one or many users have asked for, or is it convenience for a single internal caller? If the latter, make it package-private or @Internal.
  2. Pick the narrowest visibility that works. Default to package-private; widen only when the cross-package or cross-module need is real.
  3. Place it in org.apache.groovy.* for new code. Use org.codehaus.groovy.* only when the new symbol must integrate with existing internals there.
  4. Consider marking the feature as incubating if the design is still settling. Annotate API surfaces with @Incubating; for features that aren't expressed as a class or method (grammar tweaks, cross-cutting behaviour), say "incubating" in the prose documentation instead. Either form buys room to refine in a minor release. See "Incubating features" above.
  5. Document it. Public types need accurate Groovydoc/Javadoc; if the feature is user-facing, also add or update an AsciiDoc section under src/spec/doc/ or the relevant subproject's src/spec/doc/.
  6. Add tests. Both unit tests and, where appropriate, an executable spec example under src/spec/test/.

Deprecation policy

When you need to remove or replace public API:

  • Mark the old symbol @Deprecated(since = "X.Y.Z", forRemoval = true) when removal is planned, or @Deprecated(since = "X.Y.Z") when retirement is open-ended.

  • In the Javadoc, name the replacement explicitly with @deprecated and a one-line pointer:

    /**
     * @deprecated since 5.1.0, use {@link #newMethod(String)} instead.
     */
  • Keep the deprecated symbol working — same semantics, no behaviour drift — until it is removed.

  • Remove deprecated symbols only in a major release, and only after a deprecation has shipped in at least one minor release first.

  • If a behavioural change is unavoidable in the same release as the deprecation (rare), call it out in the release notes.

Groovy 6 — classic call-site caching (GROOVY-12185)

Classic (non-invokedynamic) call-site classes under org.codehaus.groovy.runtime.callsite were moved out of groovy-core into the optional module groovy-callsite.

Primary purpose: keep runtime binary compatibility for classes compiled by Groovy 4 and Groovy 5 (and earlier releases that always used classic call sites). Those classes embed $getCallSiteArray / CallSiteArray / CallSite linkage. With org.apache.groovy:groovy-callsite on the runtime classpath, that bytecode must still load and execute on Groovy 6. The published module preserves the same public linkage surface that Groovy 4/5 classic bytecode depends on (CallSiteArray(Class, String[]), public array / owner fields, and the full CallSite method set).

Core does not depend on that module; runtime dispatch uses invokedynamic by default (as since Groovy 4). Related helpers that existed only to construct classic call sites (for example MetaClassImpl#createPojoCallSite, CachedMethod#createPojoMetaMethodSite, CachedClass#getCallSiteLoader) were removed from core in this major version. Public method-selection overloads remain on MetaClassImpl for the optional runtime without package-private coupling.

To compile with indy disabled, or to run classic-mode classes from older releases, add org.apache.groovy:groovy-callsite to the classpath. Compiling with indy=false without that module fails fast with a clear error rather than emitting unloadable bytecode. Valuable helpers that used to live beside the call-site cache (BooleanClosureWrapper and related types) remain in core under org.codehaus.groovy.runtime.

The classic call-site types are not marked @Deprecated in 6.0.0 beta releases so downstream (notably Grails) can validate invokedynamic performance without formal deprecation noise. Javadoc states they are planned for deprecation/removal in a future Groovy version. Formal @Deprecated may be restored before 6 GA once beta feedback confirms indy remains acceptable for those use cases.

Groovy 6 — instanceof pattern variable flow scoping (GROOVY-12242)

Groovy 6 aligns JEP 394 / JLS §6.3-style pattern variable scoping for e instanceof T t and e !instanceof T t (equivalent to !(e instanceof T t)) with Java for the common shapes: if/else (including abrupt-completion survivors), short-circuit && / ||, ternary/Elvis arms, and true-path bindings in while bodies.

Who is affected (silent behaviour change in dynamic Groovy). Code that previously treated a pattern variable as a local outside its JLS live range — for example in an else branch of if (o instanceof String s), after a fall-through if, or on the RHS of o instanceof String s || s.isEmpty() — now resolves that name as a dynamic property. At runtime that yields MissingPropertyException (or a type-checker error under @TypeChecked / @CompileStatic), with no additional compile-time warning in dynamic mode. That is the intended Java-aligned semantics; Groovy 6 is the major version for the change.

What is not claimed. while / do-while deliberately get only partial flow scoping: true-path bindings are available in a while body and short-circuit rules apply in conditions, but Groovy does not introduce false-path bindings after a loop when the body cannot complete normally (JLS §6.3.2.3). Pattern names never leak past the loop. Full after-loop introduction is a possible future enhancement, not a 6.0 gap to back-port silently.

Release notes for the 6.0 beta line should call this out (JIRA breaking label on GROOVY-12242).

Groovy 6 — CompilerConfiguration copy constructor copies customizers (GROOVY-9585)

CompilerConfiguration(CompilerConfiguration) now copies the source configuration's compilation customizers along with every other setting. Before Groovy 6 it copied everything except customizers.

Who is affected. Code that derives a configuration from an existing one and then registers its own customizers:

CompilerConfiguration child = new CompilerConfiguration(parent);
child.addCompilationCustomizers(mine);   // now: parent's customizers *and* mine

The change is silent — no exception, just customizers running that previously did not. An ImportCustomizer adds its imports twice; an ASTTransformationCustomizer, which carries mutable applied state, is invoked for a second compilation.

To restore the old behaviour, use the two-argument copy constructor added in 6.0.0:

CompilerConfiguration child = new CompilerConfiguration(parent, false);

Nested compilation. A copied customizer is invoked for every primary class node of the child compilation, including nodes added via CompilationUnit.addClassNode, which have no SourceUnit — as that method's javadoc warns. A customizer that dereferences the SourceUnit it is handed will therefore throw NullPointerException; Gradle's incremental-compilation customizer is one such, and Groovy's own SourceAwareCustomizer is another. A customizer may equally assume the class nodes it sees belong to the compilation it was registered for. Groovy's own nested compilations — StaticTypeCheckingSupport.evaluateExpression, which compiles a synthetic expression holder, and GroovyTypeCheckingExtensionSupport, which compiles a type checking DSL script — therefore pass false. Prefer new CompilerConfiguration(parent, false) whenever you derive a configuration for a nested compilation, and null-check the SourceUnit in any customizer that might be applied to one.

The binary-compatibility check

The subprojects/binary-compatibility/ module wires japicmp into the build. For each library subproject, it compares the current repackageJar output (shadow-relocated published jar) against the published artefact of a baseline version and produces an HTML report.

Aspect Detail
Tool me.champeau.gradle.japicmp Gradle plugin
Baseline version binaryCompatibilityBaseline Gradle property (-PbinaryCompatibilityBaseline=5.0.4 or gradle.properties)
Coverage All subprojects with groovyLibrary.checkBinaryCompatibility = true (the default)
Visibility checked protected and above
Excluded Packages matching **internal** and groovyjarjar**; closure classes and dgm$* runtime helpers
Where reports land subprojects/<name>/build/reports/japicmp<Name>.html
Aggregating task ./gradlew :binary-compatibility:checkBinaryCompatibility

A few things to know about the check:

  • It does not fail the build. failOnModification = false — reports are produced, but a CI job has to read them and a human has to interpret them. The report is a guidance tool, not a gate.
  • @Internal is not what excludes a symbol from the check. Exclusion is by package name (**internal**). The annotation is a documentation marker that helps Groovydoc, AST tools, and reviewers; to genuinely move something out of the binary-compatibility surface, an internal package is the right home.
  • The baseline is a Gradle property, not pinned in code. When a release is cut, the property is updated to the previous release. Local runs default to whatever's in gradle.properties.

If japicmp flags a change you intended:

  1. Confirm the affected symbol was actually public, not internal.
  2. Confirm the change is necessary — most "improvements" to public API are not.
  3. If it stays, raise it on the dev list. The release-management conversation is the one that signs off on what version absorbs the break.

Service files and SPI

These are public surface even though they don't show up in japicmp:

  • META-INF/services/org.codehaus.groovy.transform.ASTTransformation — global AST transformations.
  • META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule — GDK-style extension modules adding methods to existing classes.
  • Anything published under META-INF/services/ or META-INF/groovy/.

Removing or renaming an entry in these files is as breaking as removing the corresponding type. New entries get added additively, the same way as a new public type.

Cross-references