diff --git a/src/main/java/org/openrewrite/java/migrate/search/FindJavaVersion.java b/src/main/java/org/openrewrite/java/migrate/search/FindJavaVersion.java index 4047ac6ed6..7a314f7249 100644 --- a/src/main/java/org/openrewrite/java/migrate/search/FindJavaVersion.java +++ b/src/main/java/org/openrewrite/java/migrate/search/FindJavaVersion.java @@ -15,43 +15,91 @@ */ package org.openrewrite.java.migrate.search; -import lombok.EqualsAndHashCode; -import lombok.Value; +import lombok.Getter; import org.openrewrite.ExecutionContext; -import org.openrewrite.Recipe; +import org.openrewrite.ScanningRecipe; +import org.openrewrite.SourceFile; +import org.openrewrite.Tree; import org.openrewrite.TreeVisitor; -import org.openrewrite.java.JavaVisitor; +import org.openrewrite.java.marker.JavaProject; import org.openrewrite.java.marker.JavaVersion; import org.openrewrite.java.migrate.table.JavaVersionTable; -import org.openrewrite.java.tree.J; +import org.openrewrite.java.tree.JavaSourceFile; +import org.openrewrite.marker.GitProvenance; +import org.openrewrite.marker.Markers; -import java.util.HashSet; -import java.util.Set; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; -@EqualsAndHashCode(callSuper = false) -@Value -public class FindJavaVersion extends Recipe { +import static java.util.Collections.emptyList; + +public class FindJavaVersion extends ScanningRecipe> { transient JavaVersionTable table = new JavaVersionTable(this); - transient Set seen = new HashSet<>(); - String displayName = "Find Java versions in use"; + @Getter + final String displayName = "Find Java versions in use"; - String description = "Finds Java versions in use."; + @Getter + final String description = "Finds Java versions in use, emitting one row per git repository " + + "(the lowest source/target compatibility across modules in that repository)."; @Override - public TreeVisitor getVisitor() { - return new JavaVisitor() { + public Map getInitialValue(ExecutionContext ctx) { + return new LinkedHashMap<>(); + } + + @Override + public TreeVisitor getScanner(Map acc) { + return new TreeVisitor() { @Override - public J visitCompilationUnit(J.CompilationUnit cu, ExecutionContext ctx) { - cu.getMarkers().findFirst(JavaVersion.class) - .filter(seen::add) - .map(jv -> new JavaVersionTable.Row( + public Tree preVisit(Tree tree, ExecutionContext ctx) { + if (tree instanceof JavaSourceFile) { + Markers markers = tree.getMarkers(); + markers.findFirst(JavaVersion.class).ifPresent(jv -> { + // Prefer the git origin as the dedup key: every module in a multi-module repo + // shares one GitProvenance, so they collapse to a single row. Fall back to the + // JavaProject UUID (one row per module) when no git provenance is available, + // and finally to the JavaVersion UUID so disconnected source files still + // produce one row per file rather than silently merging. + // Prefixes keep the three key namespaces from colliding. + String key = markers.findFirst(GitProvenance.class) + .map(GitProvenance::getOrigin) + .map(origin -> "origin:" + origin) + .orElseGet(() -> markers.findFirst(JavaProject.class) + .map(jp -> "project:" + jp.getId()) + .orElseGet(() -> "version:" + jv.getId())); + + JavaVersionTable.Row candidate = new JavaVersionTable.Row( Integer.toString(jv.getMajorVersion()), - Integer.toString(jv.getMajorReleaseVersion()))) - .ifPresent(row -> table.insertRow(ctx, row)); - return cu; + Integer.toString(jv.getMajorReleaseVersion())); + acc.merge(key, candidate, FindJavaVersion::lower); + }); + } + return tree; } }; } + + @Override + public Collection generate(Map acc, ExecutionContext ctx) { + for (JavaVersionTable.Row row : acc.values()) { + table.insertRow(ctx, row); + } + return emptyList(); + } + + // Lower target compatibility wins; tiebreak on lower source compatibility. + // The retained row is the migration floor for the repository. + private static JavaVersionTable.Row lower(JavaVersionTable.Row a, JavaVersionTable.Row b) { + int aTarget = Integer.parseInt(a.getTargetVersion()); + int bTarget = Integer.parseInt(b.getTargetVersion()); + if (aTarget != bTarget) { + return aTarget < bTarget ? a : b; + } + int aSource = Integer.parseInt(a.getSourceVersion()); + int bSource = Integer.parseInt(b.getSourceVersion()); + return aSource <= bSource ? a : b; + } } diff --git a/src/test/java/org/openrewrite/java/migrate/search/FindJavaVersionTest.java b/src/test/java/org/openrewrite/java/migrate/search/FindJavaVersionTest.java index dc670cd529..8a70c32b95 100644 --- a/src/test/java/org/openrewrite/java/migrate/search/FindJavaVersionTest.java +++ b/src/test/java/org/openrewrite/java/migrate/search/FindJavaVersionTest.java @@ -17,8 +17,10 @@ import org.junit.jupiter.api.Test; import org.openrewrite.DocumentExample; +import org.openrewrite.java.marker.JavaProject; import org.openrewrite.java.marker.JavaVersion; import org.openrewrite.java.migrate.table.JavaVersionTable; +import org.openrewrite.marker.GitProvenance; import org.openrewrite.test.RecipeSpec; import org.openrewrite.test.RewriteTest; @@ -33,30 +35,141 @@ public void defaults(RecipeSpec spec) { spec.recipe(new FindJavaVersion()); } + private static GitProvenance gitProvenance(String origin) { + return new GitProvenance(randomId(), origin, "main", "abc123", null, null, null); + } + @DocumentExample @Test - void twoClassesWithSameMarkerLeadToOneRow() { + void multiModuleRepositoryCollapsesToOneRow() { + // All modules in a multi-module repository share one GitProvenance, so the recipe + // emits a single row identifying the repository as a whole. + var git = gitProvenance("https://github.com/example/demo.git"); + var moduleA = new JavaProject(randomId(), "module-a", null); + var moduleB = new JavaProject(randomId(), "module-b", null); + var jv = new JavaVersion(randomId(), "Sam", "Shelter", "17", "8"); + rewriteRun( + spec -> spec.dataTable(JavaVersionTable.Row.class, rows -> + assertThat(rows).containsExactly( + new JavaVersionTable.Row("17", "8") + )), + //language=java + java( + """ + class A { + } + """, + spec -> spec.markers(git, moduleA, jv)), + //language=java + java( + """ + class B { + } + """, + spec -> spec.markers(git, moduleB, jv)) + ); + } + + @Test + void heterogeneousVersionsInOneRepoPickLowestTarget() { + // When modules in the same repo target different JDKs, the row reports the lowest + // target — the migration floor for the repository. + var git = gitProvenance("https://github.com/example/mixed.git"); + var legacy = new JavaProject(randomId(), "legacy-module", null); + var modern = new JavaProject(randomId(), "modern-module", null); + var java8 = new JavaVersion(randomId(), "Sam", "Shelter", "8", "8"); + var java17 = new JavaVersion(randomId(), "Sam", "Shelter", "17", "17"); + rewriteRun( + spec -> spec.dataTable(JavaVersionTable.Row.class, rows -> + assertThat(rows).containsExactly( + new JavaVersionTable.Row("8", "8") + )), + //language=java + java( + """ + class Legacy { + } + """, + spec -> spec.markers(git, legacy, java8)), + //language=java + java( + """ + class Modern { + } + """, + spec -> spec.markers(git, modern, java17)) + ); + } + + @Test + void identicalJavaVersionMarkersAcrossRepositoriesAreEachReported() { + // Reproduces customer-requests#2409: across multiple repositories with the same JDK, + // every repository must contribute its own row in the data table. Previously a + // recipe-instance HashSet deduplicated by JavaVersion content, so identical markers + // in different repositories were silently dropped. + var gitA = gitProvenance("https://github.com/example/repo-a.git"); + var gitB = gitProvenance("https://github.com/example/repo-b.git"); + var gitC = gitProvenance("https://github.com/example/repo-c.git"); + var project = new JavaProject(randomId(), "service", null); + var jv = new JavaVersion(randomId(), "Sam", "Shelter", "17", "8"); + rewriteRun( + spec -> spec.dataTable(JavaVersionTable.Row.class, rows -> + assertThat(rows).containsExactly( + new JavaVersionTable.Row("17", "8"), + new JavaVersionTable.Row("17", "8"), + new JavaVersionTable.Row("17", "8") + )), + //language=java + java( + """ + class A { + } + """, + spec -> spec.markers(gitA, project, jv)), + //language=java + java( + """ + class B { + } + """, + spec -> spec.markers(gitB, project, jv)), + //language=java + java( + """ + class C { + } + """, + spec -> spec.markers(gitC, project, jv)) + ); + } + + @Test + void withoutGitProvenanceFallsBackToPerProject() { + // When no GitProvenance is available (local non-git source trees, some test setups), + // the recipe falls back to one row per JavaProject so distinct modules are not silently merged. + var projectOne = new JavaProject(randomId(), "module-a", null); + var projectTwo = new JavaProject(randomId(), "module-b", null); var jv = new JavaVersion(randomId(), "Sam", "Shelter", "17", "8"); rewriteRun( - spec -> spec.dataTable(JavaVersionTable.Row.class, rows -> { - assertThat(rows).containsExactly( - new JavaVersionTable.Row("17", "8") - ); - }), + spec -> spec.dataTable(JavaVersionTable.Row.class, rows -> + assertThat(rows).containsExactly( + new JavaVersionTable.Row("17", "8"), + new JavaVersionTable.Row("17", "8") + )), //language=java java( """ class A { } """, - spec -> spec.markers(jv)), + spec -> spec.markers(projectOne, jv)), //language=java java( """ class B { } """, - spec -> spec.markers(jv)) + spec -> spec.markers(projectTwo, jv)) ); } }