Make a null recursive consistently mean non-recursive in ChangePackage - #8382
Conversation
…kage`
`recursive` is `@Nullable` with `required = false` and no documented default,
and null was read with two different meanings inside the same recipe: the
preconditions and the `SourceFileWithReferences` path treated it as
non-recursive, while `getNewPackageName` and `isTargetRecursivePackageName`
treated it as recursive.
For Java sources the precondition gated on the non-recursive reading and the
visitor body then acted on the recursive one, so a subpackage type was renamed
only when the file independently satisfied the non-recursive precondition — i.e.
only when it also referenced a type sitting directly in `oldPackageName`:
// no change, the only import is in a subpackage
import cucumber.api.java.en.Given;
// both rewritten, including the subpackage one
import cucumber.api.java.Before;
import cucumber.api.java.en.Given;
Null now means non-recursive everywhere, routed through a single `isRecursive()`
accessor, and documented on the `@Option`. Non-recursive means "the package
itself and the types declared directly in it", matching the `@DocumentExample`
`renameUsingSimplePackageName`, and it now means that for Java and non-Java
sources alike:
- `visitFieldAccess` no longer rewrites `oldPackageName` when it is only the
leading segments of a subpackage-qualified name such as `oldPackageName.sub.Type`.
- `PackageMatcher.matchesReference` now selects `oldPackageName.Type` when
non-recursive, which `getReplacement` was already prepared to rename but the
matcher never produced. Previously non-recursive mode was degenerate for
properties/YAML/XML/service-provider files: it matched only a bare reference
to the package name itself.
- The `JavaSourceFile` package-declaration precondition used a boundary-less
`startsWith`, a third reading of the option; it now mirrors the visitor.
This comment was marked as outdated.
This comment was marked as outdated.
…141) Both of these `ChangePackage` rules omit `recursive` while depending on it to move types out of subpackages of `oldPackageName`. `org.apache.commons.math` -> `org.apache.commons.math3` has to carry `org.apache.commons.math.stat.StatUtils` along with it. The httpclient rule is step 2 of a three-step chain: org.apache.http.entity -> org.apache.hc.core5.http.io.entity (recursive: true) org.apache.hc.core5.http.io.entity.mime -> org.apache.hc.client5.http.entity.mime org.apache.hc.client5.http.entity.mime.content -> org.apache.hc.client5.http.entity.mime Step 1 relocates `org.apache.http.entity.mime.content.StringBody` under `core5`, step 2 has to move the whole `mime` subtree over to `client5`, and step 3 flattens `.content`. Step 2 only reaches `.content.StringBody` by recursing, so without it the chain stalls at `org.apache.hc.core5.http.io.entity.mime.content.StringBody`, a package that does not exist in httpclient5. Relying on the default here was always fragile: `ChangePackage.recursive` is `@Nullable` with no documented default, and a null was read as non-recursive by the recipe's preconditions but as recursive by its visitor, so subpackage renames only happened for files that also referenced a type sitting directly in `oldPackageName`. openrewrite/rewrite#8382 settles null as non-recursive, which makes that latent dependency visible. Stating `recursive: true` is correct either way and is green both with and without that change.
This comment was marked as outdated.
This comment was marked as outdated.
|
@sambsnyd ready for review when you have a moment. Downstream validation is complete — 17 consumer repos, 3 regressions, all the same shape and all fixed and merged (openrewrite/rewrite-apache#141, moderneinc/rewrite-spring#409). The judgement call worth your eye is the default itself, written up under "The default: null means non-recursive", and the two sites beyond the six in the original report. |
Full audit of declarative
|
| PR | rule | evidence |
|---|---|---|
| openrewrite/rewrite-testing-frameworks#1072 | com.github.javafaker → net.datafaker |
3/3 subpackages map |
| openrewrite/rewrite-testing-frameworks#1072 | org.fest.assertions.api → org.assertj.core.api |
.filter maps |
| openrewrite/rewrite-migrate-java#1185 | com.fasterxml.jackson.jaxrs.json → ...jakarta.rs.json |
.annotation maps |
| openrewrite/rewrite-micronaut#144 | io.micronaut.configuration.security.ldap → io.micronaut.security.ldap |
3/3 map |
| moderneinc/rewrite-spring#410 | ...actuate.autoconfigure.logging → ...opentelemetry.autoconfigure.logging |
1/1 |
| moderneinc/rewrite-spring#410 | ...autoconfigure.web.servlet → ...webmvc.autoconfigure |
1/1 |
| moderneinc/rewrite-spring#410 | ...actuate.autoconfigure.metrics.export → ...micrometer.metrics.autoconfigure.export |
18/20 |
Where recursion would be wrong
The audit is at least as useful for what it rules out. Several renames look like prefix substitutions but are redistributions, and blanket recursion would invent packages that never existed:
- rewrite-apache — all 8 httpclient rules with subpackages fail the check.
org.apache.http→org.apache.hc.core5.httpwould map 32 of 40 subpackages to non-existent targets (org.apache.http.annotation→org.apache.hc.core5.http.annotation, which doesn't exist — it'sorg.apache.hc.core5.annotation). The recipe's non-recursive-plus-explicit-enumeration design is correct; no changes needed beyond the merged Fix parsing issue when a block is suffixed with a multi-comment #141. - rewrite-spring — of 18 rules with subpackages, 15 already have explicit sibling rules, e.g.
...autoconfigure.jms.activemq→org.springframework.boot.activemq.autoconfigure, not...jms.autoconfigure.activemq. Recursion would override the more specific mapping. - rewrite-logging-frameworks —
org.apache.log4j→org.apache.logging.log4j: log4j 1.x subpackages have no log4j2 counterparts. This was my top suspect earlier and the artifacts show it must stay non-recursive. - rewrite-migrate-java —
com.alibaba.fastjson→com.alibaba.fastjson2(only.annotationhas a counterpart; no.parser/.serializer/.support.*) andcom.sun.net.ssl→javax.net.ssl(no.internal).
Everything else — the DataNucleus set, org.apache.shiro.codec, javax.annotation.security / .sql, the Micronaut Cassandra rule, io.micrometer.prometheus, com.tngtech.archunit.library.plantuml, the Codahale and Spring Boot web-context rules — are leaf packages with no subpackages, so the default is already right for them.
None of these downstream PRs depend on this one; each is green with and without it.
Third-party recipe modulesAlso audited the external modules bundled into rewrite-third-party, by unpacking their published jars and applying the same artifact-backed check.
One near miss worth recording: quarkus's Both PRs are raised from forks and are correct independently of this one. |
`qualifiesTypeDirectlyInOldPackage` answered a question its early return could not sensibly answer: when nothing further qualifies the occurrence it is not "a type directly in the old package", it is a bare reference to `oldPackageName` itself. Flatten `visitFieldAccess` into guard clauses so the parent is destructured once and each bail-out carries its reason, and let the helper only ever answer about a real enclosing `J.FieldAccess`. The six tests were three shapes across two source kinds. Since `rewriteRun` takes several sources, the subpackage-only versus mixed contrast -- the asymmetry this pins -- now sits in a single run instead of being split across two tests that had to be read side by side. Same coverage, two tests.
) javax.sql.rowset.serial and javax.sql.rowset.spi both exist, but types in them are only renamed when recursive is set. Context: openrewrite/rewrite#8382
ChangePackage.recursiveis@Nullablewithrequired = falseand no documented default, and null was read with two different meanings inside the same recipe:Boolean.TRUE.equals)recursive == null || recursive)conditionprecondition,JavaSourceFilebranchgetNewPackageNameconditionprecondition,SourceFileWithReferencesbranchisTargetRecursivePackageNamePackageMatcherbuilt inpreVisitpostVisitFor Java sources the precondition gated on the non-recursive reading while the visitor body then acted on the recursive one. The result: a subpackage type was renamed only if the file independently satisfied the non-recursive precondition — i.e. only if it also referenced a type sitting directly in
oldPackageName.With
new ChangePackage("cucumber.api.java", "io.cucumber.java", null):Whether subpackages got renamed depended on unrelated properties of the source file. The four tests in
NullRecursiveDefaultsToNonRecursivewere written against the old code first and reproduce exactly this split.The default: null means non-recursive
Both readings have real costs; this picks the conservative one.
required = falseon aBooleanwith no stated default meansfalsethroughout OpenRewrite, andBoolean.TRUE.equals(...)was already the idiom at 4 of the 6 sites plus the entire non-Java path.recursive: truein the recipe that wants it. Under recursive, a rename happens that shouldn't, producing package names that never existed, and the fix requires auditing every call site to addrecursive: false.Non-recursive now means "the package itself and the types declared directly in it", matching the recipe's own
@DocumentExample(renameUsingSimplePackageNamerenamesimport org.openrewrite.Foowithrecursive = false). All readings route through a singleisRecursive()accessor, and the@Optiondescription states the default.Two sites beyond the six, needed to make that semantic true
Fixing only the six left "non-recursive" meaning two different things, which is the same class of defect:
visitFieldAccesswas unconditionally recursive. Inimport cucumber.api.java.en.Given;the innercucumber.api.javanode is itself a fully-qualified reference matchingoldPackageName, so it was rewritten regardless of the option — this is the actual mechanism by which subpackage imports leaked through. It now only fires when the occurrence qualifies a type declared directly inoldPackageName(checked via type attribution, falling back to a naming heuristic, with*allowed for star imports).PackageMatcher.matchesReferencewas degenerate when non-recursive. It matched onlyvalue.equals(targetPackage), so a reference likecucumber.api.java.Beforewas never selected — meaning non-recursiveChangePackagedid nothing at all to properties/YAML/XML/service-provider files unless a reference was the bare package name.getReplacementwas already prepared to rename exactly those values; the matcher just never produced them. This is a deliberate, bounded widening for non-Java sources (direct types only, never subpackages).The
JavaSourceFilepackage-declaration precondition also used a boundary-lessstartsWith— a third reading of the option — and now mirrors the visitor.Test changes
Six new tests in
NullRecursiveDefaultsToNonRecursivecover a subpackage-only source and a mixed source for both aJavaSourceFileand aSourceFileWithReferencesinput, plus opt-in (recursive = true) counterparts for both source kinds.Two existing tests pinned the old split semantics and were updated deliberately — they were the only failures across
rewrite-java,rewrite-java-test,rewrite-groovy,rewrite-kotlin,rewrite-maven,rewrite-properties,rewrite-yaml,rewrite-xmlandrewrite-java-tck:renamePackageRecursiverelied on the class-levelrecursive = nulldefault to get recursive behaviour; it now passestrueexplicitly, which is what its name always claimed.changePackageInServiceProviderFileNonRecursiveasserted thatChangePackage("org.foo", "org.bar", false)leavesorg.foo.MyImplunchanged, which contradicts the@DocumentExample. It now asserts the direct entry is renamed and aorg.foo.sub.MyImplBentry is left alone, so it still tests non-recursion.Downstream impact
Across the locally checked-out
rewrite-*/recipes-*modules (source trees only, build outputs excluded): 411 declarativeChangePackageusages — 282 omitrecursive, 124 settrue, 5 setfalse. Of the omitters, 260 have no sibling rule covering a subpackage, so those are the ones that could have been relying on the recursive reading.I validated the heaviest consumers by publishing this branch and its parent commit to
mavenLocaland diffing full test runs against each repo'sorigin/main:ChangePackageuses3 regressions across ~359 call sites, all the same shape — a rule that omits
recursivewhile depending on it — and each fixed by a singlerecursive: trueline:org.apache.commons.math→org.apache.commons.math3, which must carryorg.apache.commons.math.stat.org.apache.hc.core5.http.io.entity.mime→org.apache.hc.client5.http.entity.mime, step 2 of a three-step chain. Without recursion the chain stalls atorg.apache.hc.core5.http.io.entity.mime.content.StringBody, a package that doesn't exist in httpclient5. Worth noting the failure mode is still under-application, but a stalled chain can leave an intermediate name behind rather than an obviously missing rename.org.springframework.session.hazelcast→com.hazelcast.spring.session, which must carry...hazelcast.config.annotation.web.http. Its test passes today only because the fixture also imports a type sitting directly in the old package; a config class importing justEnableHazelcastHttpSessionwas never migrated at all. That is a latent bug this PR surfaces rather than causes.Both downstream PRs are green with and without this one, so they merge independently and in any order.
I then swept the remaining 13 consumers the same way — rewrite-cucumber-jvm, rewrite-hibernate, rewrite-jackson, rewrite-jenkins, rewrite-logging-frameworks, rewrite-micrometer, rewrite-micronaut, rewrite-netty, rewrite-rewrite, rewrite-spring-to-quarkus, rewrite-static-analysis, rewrite-struts, rewrite-testing-frameworks — and all 13 show zero new failures. (rewrite-spring-to-quarkus has one pre-existing
MigrateDatabaseDriversTestfailure about Maven<scope>, identical at baseline.)That contradicts my own prediction: I had flagged
cucumber.api→io.cucumberandorg.apache.log4j→org.apache.logging.log4jas likeliest to break and neither did. Their suites don't exercise a subpackage-only source, so this confirms no test regression rather than proving those renames are unaffected in the field — a repo importing onlycucumber.api.java.en.Givenwould still stop being migrated until that recipe setsrecursive: true. Both were already broken for that shape before this PR, since the precondition never matched such a file.17 repos validated in total, 3 regressions, all fixed and merged.
No usages of
PackageMatcheroutsideChangePackagein this repo.