openrewrite / openrewrite/rewrite
ChangeType: nested target types get an import that disagrees with how the reference is written
@timtebeek is already working on this.
Since Aug 5, 2026.
- Dominant language
- Java
- Stars
- 3.7k
- Forks
- 570
- Avg merge
- 13h 12m
- Merged PRs (30d)
- 261
Description
What version of OpenRewrite are you using?
Latest main (reproduced at a2b8834d6f).
How are you running OpenRewrite?
Directly, via RewriteTest in rewrite-java-test.
What is the smallest, simplest way to reproduce the problem?
// target: package b; public class Outer { public enum Nested { A } }
// original: package a; public enum Old { A }
rewriteRun(
spec -> spec.recipe(new ChangeType("a.Old", "b.Outer$Nested", null)),
java(
"""
import a.Old;
class Test {
Old value = Old.A;
}
""",
"""
import b.Outer;
import b.Outer.Nested;
class Test {
Outer.Nested value = Outer.Nested.A;
}
"""));
What did you expect to see? What did you see instead?
The reference is written through the outer class (Outer.Nested) while imports are added for
both the outer class and the nested type. import b.Outer.Nested; is left behind with nothing
resolving through it:
RemoveUnusedImportskeeps it — the type genuinely is referenced, so it is not unused by that
recipe's definition;ShortenFullyQualifiedTypeReferencesdoes not touchOuter.Nested.A— it shortens
package-qualified references, and this one is qualified by a type, not a package.
So the import sits there permanently, reading as dead code to every reviewer of the diff.
The underlying problem: the emitted form is not deterministic
The dangling import is one symptom. ChangeType decides the reference form in
visitIdentifier / visitFieldAccess, and decides which import to add independently in
postVisit. Whether the two agree then depends on whether AddImport.findTypeReference happens to
spot a reference, which in turn depends on incidental details of the input. The same logical change
produces four different outputs:
| output | when |
|---|---|
Outer.Nested + import Outer; |
no member access on the nested type |
Outer.Nested + both imports |
member access present (the repro above) |
Nested + import Outer.Nested; |
member access present and AddImport's internal shortener fires |
Outer.Nested + no import at all |
reference was package-qualified — does not compile |
The last row is a separate, harder bug. updateOuterClassTypes rebuilds the type tree without
attributing the intermediate name identifiers, so neither AddImport's reference check nor
ShortenFullyQualifiedTypeReferences can see that the outer class is referenced, and no import is
added at all:
new ChangeType("java.io.File", "java.util.Map$Entry", true)
class Test { → class Test {
java.io.File p; Map.Entry p; // no import java.util.Map;
} }
Further limitation: wrong import for deeper nesting
For a target nested more than one level (b.A$B$C), references are written as A.B.C but the
import added is for the owning class, import b.A.B;, rather than the outermost class b.A.
This repository's own tests assert both shapes
The inconsistency is already baked into the test suite, which is why there is no free fix:
ChangeTypeTest.replaceWithNestedTypeassertsimport java.util.Map;+Map.Entry p;ChangePackageTest.innerTypeassertsimport some.thing.X.Y;+Y foo();
Same situation, opposite expectations.
Downstream impact
Scanning the recipe ecosystem for ChangeType configurations whose target is a nested type turns
up 43 across 11 repositories. Note the trigger is wider than the documented Outer$Nested
form: JavaType.ShallowClass.build treats any uppercase-led dotted chain as nested, so
com.foo.Outer.Nested counts too, and that is how most downstream configurations are written.
| repository | nested-target ChangeType configs |
|---|---|
| rewrite-jackson | 13 (PropertyNamingStrategies.*Strategy, JsonInclude$Include) |
| rewrite-spring (v1 / v2) | 3 / 3 (ResponseEntity.BodyBuilder, PropertyMapping.Skip, DockerConnectionConfiguration$Host, ...) |
| rewrite-apache | 2 (SocketConfig.Builder, IOReactorConfig.Builder) |
| rewrite-migrate-java | 2 (JSONReader.Feature, JSONWriter.Feature) |
| rewrite-android-studio, rewrite-logging-frameworks, rewrite-spring-to-quarkus, rewrite-jackson-v1 | 1 each |
Downstream goldens likewise encode both shapes, because both are currently produced:
- expecting the bare nested name — rewrite-jackson
ReplacePropertyNamingStrategyConstantsTest,
rewrite-spring-v1JaxrsToSpringMvcResponseEntityTest - expecting the outer-qualified name — rewrite-migrate-java
UseJavaUtilBase64Test,
rewrite-testing-frameworksUpdateMockWebServerMockResponseTest, rewrite-apache
MigrateApacheHttpCoreNioTest, rewrite-spring-v2ReplaceDeprecatedDockerApiTest
rewrite-testing-frameworks's UpdateMockWebServerMockResponseTest.shouldMigrateMockResponseToBuilder
is worth calling out: one file there contains both an import-bound reference and a
package-qualified one, so any rule that treats those two differently emits both forms in the same
file.
Two live recipes are affected by the missing-import variant today: rewrite-apache's
IOReactorConfig.Builder migration and rewrite-spring-v1's ResponseEntity.BodyBuilder migration.
Originally reported downstream
- openrewrite/rewrite-cucumber-jvm#47 (item 7c):
cucumber.api.SnippetType→
io.cucumber.junit.CucumberOptions$SnippetTypeturns
@CucumberOptions(snippets = SnippetType.CAMELCASE)into
@CucumberOptions(snippets = CucumberOptions.SnippetType.CAMELCASE)alongside a freshly added
import io.cucumber.junit.CucumberOptions.SnippetType;that nothing then uses by that name.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.