From 7509a6bbb06e4e98940d01cb60dd8f3a62d8c381 Mon Sep 17 00:00:00 2001 From: lpilastri Date: Mon, 2 Nov 2026 15:52:49 +0100 Subject: [PATCH 1/6] SONARJAVA-7279 Resolve the JDK API of the target release through the new jdt-package option --- .../checks/CollectorsToListCheckSample.java | 8 +- ...ctorsToListCheckSampleWithoutSemantic.java | 8 +- .../StringIsBlankCheckSample_java10.java | 11 +- .../java/checks/RunFinalizersCheckTest.java | 7 +- java-frontend/pom.xml | 2 +- .../org/sonar/java/model/JParserConfig.java | 94 +++++++++--- .../sonar/java/model/JParserConfigTest.java | 145 ++++++++++++++++++ .../org/sonar/java/model/JParserTest.java | 75 +++++++++ 8 files changed, 308 insertions(+), 42 deletions(-) diff --git a/java-checks-test-sources/default/src/main/java/checks/CollectorsToListCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/CollectorsToListCheckSample.java index f6e4636a7b6..6b10c9139c8 100644 --- a/java-checks-test-sources/default/src/main/java/checks/CollectorsToListCheckSample.java +++ b/java-checks-test-sources/default/src/main/java/checks/CollectorsToListCheckSample.java @@ -123,22 +123,22 @@ void compliant() { List list6 = Stream.of("B", "C", "D") .collect(Collectors.toList()); // Compliant, list6 needs to be mutable - list6.addFirst("A"); + list6.add(0, "A"); List list7 = Stream.of("A", "B", "C") .collect(Collectors.toList()); // Compliant, list7 needs to be mutable - list7.addLast("D"); + list7.add("D"); List list8 = Stream.of("A", "B", "C") .collect(Collectors.toList()); // Compliant, list8 needs to be mutable - list8.removeFirst(); + list8.remove(0); List list9 = Stream.of("A", "B", "C") .collect(Collectors.toList()); // Compliant, list9 needs to be mutable - list9.removeLast(); + list9.remove("C"); } void addX() { diff --git a/java-checks-test-sources/default/src/main/java/checks/CollectorsToListCheckSampleWithoutSemantic.java b/java-checks-test-sources/default/src/main/java/checks/CollectorsToListCheckSampleWithoutSemantic.java index a5a3514ecd7..cec32cebcd0 100644 --- a/java-checks-test-sources/default/src/main/java/checks/CollectorsToListCheckSampleWithoutSemantic.java +++ b/java-checks-test-sources/default/src/main/java/checks/CollectorsToListCheckSampleWithoutSemantic.java @@ -118,22 +118,22 @@ void compliant() { List list6 = Stream.of("B", "C", "D") .collect(Collectors.toList()); // Compliant, list6 needs to be mutable - list6.addFirst("A"); + list6.add(0, "A"); List list7 = Stream.of("A", "B", "C") .collect(Collectors.toList()); // Compliant, list7 needs to be mutable - list7.addLast("D"); + list7.add("D"); List list8 = Stream.of("A", "B", "C") .collect(Collectors.toList()); // Compliant, list8 needs to be mutable - list8.removeFirst(); + list8.remove(0); List list9 = Stream.of("A", "B", "C") .collect(Collectors.toList()); // Compliant, list9 needs to be mutable - list9.removeLast(); + list9.remove("C"); } void addX() { diff --git a/java-checks-test-sources/default/src/main/java/checks/StringIsBlankCheckSample_java10.java b/java-checks-test-sources/default/src/main/java/checks/StringIsBlankCheckSample_java10.java index cece611f17c..449d8a5d8f5 100644 --- a/java-checks-test-sources/default/src/main/java/checks/StringIsBlankCheckSample_java10.java +++ b/java-checks-test-sources/default/src/main/java/checks/StringIsBlankCheckSample_java10.java @@ -4,16 +4,7 @@ public class StringIsBlankCheckSample_java10 { public boolean sample(String s) { boolean b; - // `String.isBlank()` does not exist before Java 11, so these stay emptiness checks on the trimmed string - - b = s.strip().length() == 0; // Noncompliant {{Use "isEmpty()" to check whether a "String" is empty or not.}} [[quickfixes=qf1]] - // fix@qf1 {{Replace with "isEmpty()"}} - // edit@qf1 [[sc=19;ec=32]]{{isEmpty()}} - - b = s.strip().length() > 0; // Noncompliant {{Use "isEmpty()" to check whether a "String" is empty or not.}} [[quickfixes=qf2]] - // fix@qf2 {{Replace with "isEmpty()"}} - // edit@qf2 [[sc=9;ec=9]]{{!}} - // edit@qf2 [[sc=19;ec=31]]{{isEmpty()}} + // `String.isBlank()` does not exist before Java 11, so trimmed strings are plain emptiness checks b = s.trim().length() == 0; // Noncompliant {{Use "isEmpty()" to check whether a "String" is empty or not.}} [[quickfixes=qf3]] // fix@qf3 {{Replace with "isEmpty()"}} diff --git a/java-checks/src/test/java/org/sonar/java/checks/RunFinalizersCheckTest.java b/java-checks/src/test/java/org/sonar/java/checks/RunFinalizersCheckTest.java index 8442bd53f08..568039fa06d 100644 --- a/java-checks/src/test/java/org/sonar/java/checks/RunFinalizersCheckTest.java +++ b/java-checks/src/test/java/org/sonar/java/checks/RunFinalizersCheckTest.java @@ -47,7 +47,8 @@ void test() { .withJavaVersion(11) .verifyNoIssues(); } else { - // No issue raised starting JDK 11 as the related APIs were removed from JDK and cannot be resolved + // No issue raised by default starting JDK 11 as the related APIs were removed from JDK and cannot be resolved, + // unless the analyzed Java version is 10, for which the APIs of that release are visible CheckVerifier.newVerifier() .onFile(nonCompilingTestSourcesPath("checks/RunFinalizersCheckNoIssue.java")) .withCheck(new RunFinalizersCheck()) @@ -58,10 +59,10 @@ void test() { .withJavaVersion(javaVersion) .verifyNoIssues(); CheckVerifier.newVerifier() - .onFile(nonCompilingTestSourcesPath("checks/RunFinalizersCheckNoIssue.java")) + .onFile(nonCompilingTestSourcesPath("checks/RunFinalizersCheckSample.java")) .withCheck(new RunFinalizersCheck()) .withJavaVersion(10) - .verifyNoIssues(); + .verifyIssues(); } } diff --git a/java-frontend/pom.xml b/java-frontend/pom.xml index 016dbbde0b9..060cc981c4a 100644 --- a/java-frontend/pom.xml +++ b/java-frontend/pom.xml @@ -21,7 +21,7 @@ ${project.groupId} jdt-package - 1.9.0.1840 + 1.9.0.12280 diff --git a/java-frontend/src/main/java/org/sonar/java/model/JParserConfig.java b/java-frontend/src/main/java/org/sonar/java/model/JParserConfig.java index 79cffd79a90..0da977a8a50 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/JParserConfig.java +++ b/java-frontend/src/main/java/org/sonar/java/model/JParserConfig.java @@ -16,25 +16,13 @@ */ package org.sonar.java.model; -import java.io.File; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.TimeUnit; -import java.util.function.BiConsumer; -import java.util.function.BooleanSupplier; -import java.util.stream.StreamSupport; import org.eclipse.core.runtime.OperationCanceledException; import org.eclipse.jdt.core.JavaCore; import org.eclipse.jdt.core.dom.AST; import org.eclipse.jdt.core.dom.ASTParser; import org.eclipse.jdt.core.dom.CompilationUnit; import org.eclipse.jdt.core.dom.FileASTRequestor; +import org.eclipse.jdt.core.dom.SystemReleaseClasspath; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.sonar.api.batch.fs.InputFile; @@ -47,6 +35,21 @@ import org.sonarsource.analyzer.commons.ProgressReport; import org.sonarsource.performance.measure.PerformanceMeasure; +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.BooleanSupplier; +import java.util.stream.StreamSupport; + public abstract class JParserConfig { public static final JavaVersion MAXIMUM_SUPPORTED_JAVA_VERSION = new JavaVersionImpl(JavaVersionImpl.MAX_SUPPORTED, true); @@ -54,7 +57,9 @@ public abstract class JParserConfig { private static final Logger LOG = LoggerFactory.getLogger(JParserConfig.class); private static final String MAXIMUM_ECJ_WARNINGS = "42000"; - private static final Set JRE_JARS = new HashSet<>(Arrays.asList("rt.jar", "jrt-fs.jar", "android.jar")); + private static final String JRT_FS_JAR = "jrt-fs.jar"; + private static final Set NON_MODULAR_JRE_JAR = new HashSet<>(Arrays.asList("rt.jar", "android.jar")); + private static final Set JRE_JARS = new HashSet<>(Arrays.asList("rt.jar", JRT_FS_JAR, "android.jar")); final JavaVersion javaVersion; final List classpath; @@ -67,7 +72,7 @@ private JParserConfig(JavaVersion javaVersion, List classpath, boolean sho } public abstract void parse(Iterable inputFiles, BooleanSupplier isCanceled, - AnalysisProgress analysisProgress, BiConsumer action); + AnalysisProgress analysisProgress, BiConsumer action); public enum Mode { BATCH(Batch::new), @@ -128,14 +133,17 @@ public ASTParser astParser() { JProblem.Type.compilerOptions() .forEach(option -> options.put(option, "warning")); + options.put(SystemReleaseClasspath.OPTION_RELEASE, javaVersion.effectiveJavaVersionAsString()); + List environment = useReleaseApiOfConfiguredJdk(options); + astParser.setCompilerOptions(options); - boolean includeRunningVMBootclasspath = classpath.stream() + boolean includeRunningVMBootclasspath = environment.stream() .noneMatch(f -> JRE_JARS.contains(f.getName())); - astParser.setEnvironment(classpath.stream() + astParser.setEnvironment(environment.stream() .map(File::getAbsolutePath) - .toArray(String[]::new), new String[] {}, new String[] {}, includeRunningVMBootclasspath); + .toArray(String[]::new), new String[]{}, new String[]{}, includeRunningVMBootclasspath); astParser.setResolveBindings(true); astParser.setBindingsRecovery(true); @@ -143,6 +151,51 @@ public ASTParser astParser() { return astParser; } + /** + * When the classpath carries the runtime image of a modular JDK (the {@code lib/jrt-fs.jar} added for + * {@code sonar.java.jdkHome}), ECJ would see the whole API of that JDK whatever the analyzed Java version is. + * If the requested release is older than that JDK, the image is removed from the classpath and the JDK is + * registered in {@code options} as the provider of the release API, which {@link SystemReleaseClasspath} then + * serves from its {@code ct.sym}. + * + *

Nothing changes when another JDK-like entry is present ({@code rt.jar}, {@code android.jar}), since it is + * the actual platform to analyze against, or when the release cannot be served by that JDK. + * + * @param options the compiler options, to which the JDK home is added when the release API is used + * @return the classpath to give to ECJ + */ + @VisibleForTesting + List useReleaseApiOfConfiguredJdk(Map options) { + boolean hasOtherPlatformJar = classpath.stream() + .anyMatch(f -> NON_MODULAR_JRE_JAR.contains(f.getName())); + if (hasOtherPlatformJar) { + return classpath; + } + for (File runtimeImage : classpath) { + Optional jdkHome = jdkHomeOfRuntimeImage(runtimeImage); + if (jdkHome.isPresent()) { + Map candidate = new HashMap<>(options); + candidate.put(SystemReleaseClasspath.OPTION_JAVA_HOME, jdkHome.get().getAbsolutePath()); + if (SystemReleaseClasspath.isApplicable(candidate)) { + options.put(SystemReleaseClasspath.OPTION_JAVA_HOME, jdkHome.get().getAbsolutePath()); + return classpath.stream().filter(f -> !f.equals(runtimeImage)).toList(); + } + LOG.debug("The API of Java {} is not served from the JDK '{}': using the API of that JDK as is.", + javaVersion.effectiveJavaVersionAsString(), jdkHome.get()); + return classpath; + } + } + return classpath; + } + + private static Optional jdkHomeOfRuntimeImage(File file) { + File lib = file.getParentFile(); + if (JRT_FS_JAR.equals(file.getName()) && lib != null && "lib".equals(lib.getName())) { + return Optional.ofNullable(lib.getParentFile()); + } + return Optional.empty(); + } + @VisibleForTesting static class Batch extends JParserConfig { @@ -152,7 +205,7 @@ static class Batch extends JParserConfig { @Override public void parse(Iterable inputFiles, BooleanSupplier isCanceled, - AnalysisProgress analysisProgress, BiConsumer action) { + AnalysisProgress analysisProgress, BiConsumer action) { List sourceFilePaths = new ArrayList<>(); Set notYetAnalyzedFiles = new LinkedHashSet<>(); @@ -230,7 +283,7 @@ private FileByFile(JavaVersion javaVersion, List classpath, boolean should @Override public void parse(Iterable inputFiles, BooleanSupplier isCanceled, - AnalysisProgress analysisProgress, BiConsumer action) { + AnalysisProgress analysisProgress, BiConsumer action) { boolean successfullyCompleted = false; boolean cancelled = false; @@ -280,6 +333,7 @@ private static void parse(ASTParser astParser, InputFile inputFile, JavaVersion static boolean shouldEnablePreviewFlag(JavaVersion currentVersion) { return currentVersion.arePreviewFeaturesEnabled(); } + @FunctionalInterface public interface ParserConfigConstructor { JParserConfig apply(JavaVersion version, List files, Boolean shouldIgnoreUnnamedModuleForSplitPackage); diff --git a/java-frontend/src/test/java/org/sonar/java/model/JParserConfigTest.java b/java-frontend/src/test/java/org/sonar/java/model/JParserConfigTest.java index 78d488f74b7..1aaa3b75dd6 100644 --- a/java-frontend/src/test/java/org/sonar/java/model/JParserConfigTest.java +++ b/java-frontend/src/test/java/org/sonar/java/model/JParserConfigTest.java @@ -16,9 +16,18 @@ */ package org.sonar.java.model; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.eclipse.jdt.core.dom.SystemReleaseClasspath; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; import org.slf4j.event.Level; import org.sonar.java.testing.ThreadLocalLogTester; @@ -29,6 +38,9 @@ class JParserConfigTest { @RegisterExtension public ThreadLocalLogTester logTester = new ThreadLocalLogTester().setLevel(Level.INFO); + @TempDir + Path temp; + @Test void should_enable_preview() { assertThat(shouldEnablePreviewFlag(new JavaVersionImpl())).isFalse(); @@ -55,4 +67,137 @@ void a_debug_message_is_logged_when_shouldIgnoreUnnamedModuleForSplitPackage_is_ JParserConfig.Mode.BATCH.create(new JavaVersionImpl(17), Collections.emptyList(), true); assertThat(logTester.logs()).containsExactly("The Java analyzer will ignore the unnamed module for split packages."); } + + @Test + void runtime_image_of_a_jdk_is_replaced_by_its_release_api_when_the_release_is_older() throws IOException { + File runtimeImage = runtimeImageOf(fakeJdk("jdk21", "21.0.2", true)); + File library = new File("lib.jar"); + Map options = releaseOptions("11"); + + List environment = config("11", List.of(library, runtimeImage)).useReleaseApiOfConfiguredJdk(options); + + assertThat(environment).containsExactly(library); + assertThat(options).containsEntry(SystemReleaseClasspath.OPTION_JAVA_HOME, runtimeImage.getParentFile().getParent()); + } + + @Test + void the_other_entries_of_the_classpath_are_kept_in_order() throws IOException { + File runtimeImage = runtimeImageOf(fakeJdk("jdk21", "21.0.2", true)); + List classpath = List.of(new File("a.jar"), runtimeImage, new File("b.jar"), new File("target/classes")); + + List environment = config("11", classpath).useReleaseApiOfConfiguredJdk(releaseOptions("11")); + + assertThat(environment).containsExactly(new File("a.jar"), new File("b.jar"), new File("target/classes")); + } + + @Test + void runtime_image_is_kept_when_the_release_is_not_older_than_the_jdk() throws IOException { + File runtimeImage = runtimeImageOf(fakeJdk("jdk21", "21.0.2", true)); + List classpath = List.of(runtimeImage); + + assertKept("21", classpath); + assertKept("22", classpath); + assertKept("26", classpath); + } + + @Test + void runtime_image_is_kept_when_the_release_is_below_9() throws IOException { + List classpath = List.of(runtimeImageOf(fakeJdk("jdk21", "21.0.2", true))); + + assertKept("8", classpath); + assertKept("7", classpath); + } + + @Test + void runtime_image_is_kept_when_the_jdk_cannot_serve_the_release() throws IOException { + assertKept("11", List.of(runtimeImageOf(fakeJdk("without-ct-sym", "21.0.2", false)))); + + Path withoutReleaseFile = Files.createDirectories(temp.resolve("without-release-file/lib")); + Files.createFile(withoutReleaseFile.resolve("ct.sym")); + assertKept("11", List.of(runtimeImageOf(withoutReleaseFile.getParent()))); + } + + @Test + void a_debug_message_explains_why_the_release_api_is_not_used() throws IOException { + logTester.setLevel(Level.DEBUG); + File runtimeImage = runtimeImageOf(fakeJdk("jdk21", "21.0.2", true)); + + config("21", List.of(runtimeImage)).useReleaseApiOfConfiguredJdk(releaseOptions("21")); + + assertThat(logTester.logs(Level.DEBUG)).hasSize(1); + assertThat(logTester.logs(Level.DEBUG).get(0)).contains("Java 21").contains(runtimeImage.getParentFile().getParent()); + } + + @Test + void runtime_image_is_kept_when_a_platform_jar_is_on_the_classpath() throws IOException { + File runtimeImage = runtimeImageOf(fakeJdk("jdk21", "21.0.2", true)); + + assertKept("11", List.of(new File("jdk8/jre/lib/rt.jar"), runtimeImage)); + assertKept("11", List.of(runtimeImage, new File("android-sdk/platforms/android-34/android.jar"))); + } + + @Test + void rt_jar_alone_is_not_modified() { + assertKept("11", List.of(new File("jdk8/jre/lib/rt.jar"))); + } + + @Test + void jar_named_as_runtime_image_outside_of_a_lib_folder_is_not_considered_as_a_jdk() throws IOException { + Path home = Files.createDirectories(temp.resolve("not-a-jdk")); + Files.createFile(home.resolve("jrt-fs.jar")); + + assertKept("11", List.of(home.resolve("jrt-fs.jar").toFile())); + } + + @Test + void classpath_without_jdk_is_not_modified() { + Map options = releaseOptions("11"); + List classpath = List.of(new File("a.jar"), new File("b.jar")); + + assertThat(config("11", classpath).useReleaseApiOfConfiguredJdk(options)).isSameAs(classpath); + assertThat(options).doesNotContainKey(SystemReleaseClasspath.OPTION_JAVA_HOME); + } + + @Test + void empty_classpath_is_not_modified() { + assertKept("11", List.of()); + } + + private void assertKept(String release, List classpath) { + Map options = releaseOptions(release); + + List environment = config(release, classpath).useReleaseApiOfConfiguredJdk(options); + + assertThat(environment).isSameAs(classpath); + assertThat(options).doesNotContainKey(SystemReleaseClasspath.OPTION_JAVA_HOME); + } + + private static JParserConfig config(String release, List classpath) { + return JParserConfig.Mode.FILE_BY_FILE.create(JavaVersionImpl.fromString(release), classpath); + } + + private static Map releaseOptions(String release) { + Map options = new HashMap<>(); + options.put(SystemReleaseClasspath.OPTION_RELEASE, release); + return options; + } + + private static File runtimeImageOf(Path jdkHome) throws IOException { + Path image = jdkHome.resolve("lib/jrt-fs.jar"); + Files.createDirectories(image.getParent()); + if (!Files.exists(image)) { + Files.createFile(image); + } + return image.toFile(); + } + + private Path fakeJdk(String name, String version, boolean withCtSym) throws IOException { + Path home = Files.createDirectories(temp.resolve(name)); + Path lib = Files.createDirectories(home.resolve("lib")); + Files.writeString(home.resolve("release"), "JAVA_VERSION=\"" + version + "\"\n"); + if (withCtSym) { + Files.createFile(lib.resolve("ct.sym")); + } + return home; + } } diff --git a/java-frontend/src/test/java/org/sonar/java/model/JParserTest.java b/java-frontend/src/test/java/org/sonar/java/model/JParserTest.java index 57d8dfeb19b..4aea4c7eded 100644 --- a/java-frontend/src/test/java/org/sonar/java/model/JParserTest.java +++ b/java-frontend/src/test/java/org/sonar/java/model/JParserTest.java @@ -38,6 +38,7 @@ import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; import java.util.jar.Manifest; +import java.util.stream.Collectors; import org.apache.commons.io.FileUtils; import org.eclipse.core.runtime.OperationCanceledException; import org.eclipse.jdt.core.JavaCore; @@ -91,6 +92,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doReturn; @@ -111,6 +113,79 @@ class JParserTest { @RegisterExtension public ThreadLocalLogTester logTester = new ThreadLocalLogTester().setLevel(Level.DEBUG); + @Test + void should_only_see_the_jdk_api_of_the_requested_release() { + assumeRunningJdkCanServeOlderReleases(); + String source = """ + class Example { + java.util.jar.Pack200 removedInJdk14; + java.lang.Record addedInJdk16; + } + """; + Set undefinedAt11 = undefinedTypes("11", source); + assertThat(undefinedAt11).containsExactly("java.lang.Record cannot be resolved to a type"); + assertThat(undefinedTypes("17", source)).containsExactly("java.util.jar.Pack200 cannot be resolved to a type"); + } + + @Test + void should_use_the_release_api_of_the_jdk_provided_in_the_classpath() { + File runtimeImage = new File(System.getProperty("java.home"), "lib/jrt-fs.jar"); + assumeTrue(runtimeImage.isFile()); + assumeRunningJdkCanServeOlderReleases(); + String source = """ + class Example { + java.util.jar.Pack200 removedInJdk14; + java.lang.Record addedInJdk16; + } + """; + List classpath = List.of(runtimeImage); + int jdkVersion = Runtime.version().feature(); + + assertThat(undefinedTypes("11", source, classpath)).containsExactly("java.lang.Record cannot be resolved to a type"); + assertThat(undefinedTypes(Integer.toString(jdkVersion), source, classpath)).containsExactly("java.util.jar.Pack200 cannot be resolved to a type"); + assertThat(undefinedTypes(Integer.toString(jdkVersion + 1), source, classpath)).containsExactly("java.util.jar.Pack200 cannot be resolved to a type"); + } + + @Test + void should_only_see_the_jdk_api_of_the_requested_release_in_batch_mode(@TempDir Path temp) throws Exception { + assumeRunningJdkCanServeOlderReleases(); + Path file = Files.writeString(temp.resolve("Example.java"), """ + class Example { + java.util.jar.Pack200 removedInJdk14; + java.lang.Record addedInJdk16; + } + """); + List> undefinedTypes = new ArrayList<>(); + + BATCH + .create(JavaVersionImpl.fromString("11"), List.of()) + .parse(Set.of(TestUtils.inputFile(file.toFile())), () -> false, new AnalysisProgress(1), (inputFile, result) -> { + try { + undefinedTypes.add(result.get().sema.undefinedTypes.stream().map(Object::toString).collect(Collectors.toSet())); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + + assertThat(undefinedTypes).containsExactly(Set.of("java.lang.Record cannot be resolved to a type")); + } + + private static void assumeRunningJdkCanServeOlderReleases() { + assumeTrue(new File(System.getProperty("java.home"), "lib/ct.sym").isFile(), "the running JVM has no lib/ct.sym"); + assumeTrue(Runtime.version().feature() > 17, "the running JVM must be newer than Java 17"); + } + + private static Set undefinedTypes(String release, String source) { + return undefinedTypes(release, source, List.of()); + } + + private static Set undefinedTypes(String release, String source, List classpath) { + ASTParser astParser = FILE_BY_FILE.create(JavaVersionImpl.fromString(release), classpath).astParser(); + return JParser.parse(astParser, release, "Example", source).sema.undefinedTypes.stream() + .map(Object::toString) + .collect(Collectors.toSet()); + } + @Test void should_throw_RecognitionException_in_case_of_syntax_error() { // Note that without check for syntax errors will cause IndexOutOfBoundsException From 132d6b47996ba2bcbd54ef1c965cce9a4effde80 Mon Sep 17 00:00:00 2001 From: lpilastri Date: Mon, 2 Nov 2026 16:09:22 +0100 Subject: [PATCH 2/6] SONARJAVA-7279 Keep coverage of the SequencedCollection mutators in CollectorsToListCheck The main sample runs at Java 16, where addFirst, addLast, removeFirst and removeLast no longer resolve, so it uses the older List methods instead. A dedicated sample checked at Java 21 keeps one compliant case for each of the four methods, and a noncompliant case as a control. Co-Authored-By: Claude Sonnet 5.5 --- .../CollectorsToListCheckSequencedSample.java | 38 +++++++++++++++++++ .../checks/CollectorsToListCheckTest.java | 9 +++++ 2 files changed, 47 insertions(+) create mode 100644 java-checks-test-sources/default/src/main/java/checks/CollectorsToListCheckSequencedSample.java diff --git a/java-checks-test-sources/default/src/main/java/checks/CollectorsToListCheckSequencedSample.java b/java-checks-test-sources/default/src/main/java/checks/CollectorsToListCheckSequencedSample.java new file mode 100644 index 00000000000..e5621b3cad9 --- /dev/null +++ b/java-checks-test-sources/default/src/main/java/checks/CollectorsToListCheckSequencedSample.java @@ -0,0 +1,38 @@ +package checks; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class CollectorsToListCheckSequencedSample { + + void noncompliant() { + List list = Stream.of("A", "B", "C") + .collect(Collectors.toList()); // Noncompliant {{Replace this usage of 'Stream.collect(Collectors.toList())' with 'Stream.toList()' and ensure that the list is unmodified.}} +// ^^^^^^^^^^^^^^^^^^^ + + list.getFirst(); + } + + void compliant() { + List addFirst = Stream.of("B", "C", "D") + .collect(Collectors.toList()); // Compliant, addFirst needs to be mutable + + addFirst.addFirst("A"); + + List addLast = Stream.of("A", "B", "C") + .collect(Collectors.toList()); // Compliant, addLast needs to be mutable + + addLast.addLast("D"); + + List removeFirst = Stream.of("A", "B", "C") + .collect(Collectors.toList()); // Compliant, removeFirst needs to be mutable + + removeFirst.removeFirst(); + + List removeLast = Stream.of("A", "B", "C") + .collect(Collectors.toList()); // Compliant, removeLast needs to be mutable + + removeLast.removeLast(); + } +} diff --git a/java-checks/src/test/java/org/sonar/java/checks/CollectorsToListCheckTest.java b/java-checks/src/test/java/org/sonar/java/checks/CollectorsToListCheckTest.java index 5df09c6fd2c..d6875606d0c 100644 --- a/java-checks/src/test/java/org/sonar/java/checks/CollectorsToListCheckTest.java +++ b/java-checks/src/test/java/org/sonar/java/checks/CollectorsToListCheckTest.java @@ -28,6 +28,15 @@ void test() { .verifyIssues(); } + @Test + void test_sequenced_collection_mutators() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath("checks/CollectorsToListCheckSequencedSample.java")) + .withCheck(new CollectorsToListCheck()) + .withJavaVersion(21) + .verifyIssues(); + } + @Test void test_no_issues_for_older_versions() { createCheckVerifier() From b00b4397958b71af836ecf83db9da758975766a6 Mon Sep 17 00:00:00 2001 From: lpilastri Date: Mon, 2 Nov 2026 16:24:26 +0100 Subject: [PATCH 3/6] SONARJAVA-7279 Update ruling expectations for Guava Guava is analyzed with sonar.java.source=17, so the API of Java 17 is now seen instead of the one of the JDK running the analysis: - ThreadDeath (deprecated for removal in 20) and Object.finalize() (in 18) are no longer reported by S5738, - the unsynchronized mark() and reset() overrides in HashingInputStream are now reported by S3551, as FilterInputStream declares them synchronized in 17. Co-Authored-By: Claude Sonnet 5.5 --- .../src/test/resources/expected/guava/java-S3551.json | 6 ++++++ .../src/test/resources/expected/guava/java-S5738.json | 8 -------- 2 files changed, 6 insertions(+), 8 deletions(-) create mode 100644 its/ruling/src/test/resources/expected/guava/java-S3551.json diff --git a/its/ruling/src/test/resources/expected/guava/java-S3551.json b/its/ruling/src/test/resources/expected/guava/java-S3551.json new file mode 100644 index 00000000000..07f09f6cc06 --- /dev/null +++ b/its/ruling/src/test/resources/expected/guava/java-S3551.json @@ -0,0 +1,6 @@ +{ +"guava:src/com/google/common/hash/HashingInputStream.java": [ +88, +95 +] +} diff --git a/its/ruling/src/test/resources/expected/guava/java-S5738.json b/its/ruling/src/test/resources/expected/guava/java-S5738.json index 1aff35a77db..df812fefdce 100644 --- a/its/ruling/src/test/resources/expected/guava/java-S5738.json +++ b/its/ruling/src/test/resources/expected/guava/java-S5738.json @@ -1,15 +1,7 @@ { -"guava:src/com/google/common/base/Throwables.java": [ -337, -367, -370 -], "guava:src/com/google/common/cache/Striped64.java": [ 321 ], -"guava:src/com/google/common/io/FileBackedOutputStream.java": [ -103 -], "guava:src/com/google/common/primitives/UnsignedBytes.java": [ 364 ], From ad95d3aec4f02b1e0c492d1e046c038e4947e706 Mon Sep 17 00:00:00 2001 From: lpilastri Date: Mon, 2 Nov 2026 16:42:13 +0100 Subject: [PATCH 4/6] SONARJAVA-7279 Remove ruling expectations that no longer have any issue The analysis now sees the API of the Java release it targets instead of the one of the JDK running it, so two sets of expected issues disappear: - eclipse-jetty S5738: Jetty targets Java 11, where SecurityManager and AccessController are not yet deprecated for removal (Java 17), - guava S2387: the shadowed fields (Reference.next, AbstractMap.keySet) are not public API, and are not part of the API of the release anymore. Co-Authored-By: Claude Sonnet 5.5 --- .../expected/eclipse-jetty/java-S5738.json | 28 ------------------- .../resources/expected/guava/java-S2387.json | 17 ----------- 2 files changed, 45 deletions(-) delete mode 100644 its/ruling/src/test/resources/expected/eclipse-jetty/java-S5738.json delete mode 100644 its/ruling/src/test/resources/expected/guava/java-S2387.json diff --git a/its/ruling/src/test/resources/expected/eclipse-jetty/java-S5738.json b/its/ruling/src/test/resources/expected/eclipse-jetty/java-S5738.json deleted file mode 100644 index e14b0890cc3..00000000000 --- a/its/ruling/src/test/resources/expected/eclipse-jetty/java-S5738.json +++ /dev/null @@ -1,28 +0,0 @@ -{ -"eclipse-jetty:jetty-server/src/main/java/org/eclipse/jetty/server/handler/ContextHandler.java": [ -212, -320, -2509, -2518, -3041 -], -"eclipse-jetty:jetty-slf4j-impl/src/main/java/org/eclipse/jetty/logging/JettyLoggerConfiguration.java": [ -169 -], -"eclipse-jetty:jetty-util/src/main/java/org/eclipse/jetty/util/MemoryUtils.java": [ -37 -], -"eclipse-jetty:jetty-util/src/main/java/org/eclipse/jetty/util/TypeUtil.java": [ -680 -], -"eclipse-jetty:jetty-util/src/test/java/org/eclipse/jetty/util/TypeUtilTest.java": [ -198, -216 -], -"eclipse-jetty:jetty-util/src/test/java/org/eclipse/jetty/util/thread/QueuedThreadPoolTest.java": [ -730 -], -"eclipse-jetty:jetty-xml/src/main/java/org/eclipse/jetty/xml/XmlConfiguration.java": [ -1761 -] -} diff --git a/its/ruling/src/test/resources/expected/guava/java-S2387.json b/its/ruling/src/test/resources/expected/guava/java-S2387.json deleted file mode 100644 index b2438d3406c..00000000000 --- a/its/ruling/src/test/resources/expected/guava/java-S2387.json +++ /dev/null @@ -1,17 +0,0 @@ -{ -"guava:src/com/google/common/cache/LocalCache.java": [ -1370, -4230, -4239 -], -"guava:src/com/google/common/collect/MapMakerInternalMap.java": [ -1241, -1499, -3566, -3574 -], -"guava:src/com/google/common/collect/Maps.java": [ -3384, -3395 -] -} From 13b317613372d9844d07d2883a7a1006e9978ddf Mon Sep 17 00:00:00 2001 From: lpilastri Date: Mon, 2 Nov 2026 17:03:17 +0100 Subject: [PATCH 5/6] SONARJAVA-7279 Remove the S5738 ruling expectations of eclipse-jetty-similar-to-main Same reason as for eclipse-jetty: Jetty targets Java 11, where SecurityManager is not deprecated for removal yet, so the issues of ContextHandler are gone. Co-Authored-By: Claude Sonnet 5.5 --- .../eclipse-jetty-similar-to-main/java-S5738.json | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 its/ruling/src/test/resources/expected/eclipse-jetty-similar-to-main/java-S5738.json diff --git a/its/ruling/src/test/resources/expected/eclipse-jetty-similar-to-main/java-S5738.json b/its/ruling/src/test/resources/expected/eclipse-jetty-similar-to-main/java-S5738.json deleted file mode 100644 index 0277d1f2890..00000000000 --- a/its/ruling/src/test/resources/expected/eclipse-jetty-similar-to-main/java-S5738.json +++ /dev/null @@ -1,9 +0,0 @@ -{ -"eclipse-jetty:jetty-server/src/main/java/org/eclipse/jetty/server/handler/ContextHandler.java": [ -212, -320, -2509, -2518, -3041 -] -} From ac3fd7c04407f55dbee922a661a7ca730a73d99e Mon Sep 17 00:00:00 2001 From: Leonardo Pilastri Date: Tue, 3 Nov 2026 15:12:52 +0100 Subject: [PATCH 6/6] SONARJAVA-7279 Guard the debug log of the release API fallback with isDebugEnabled Co-Authored-By: Claude Sonnet 5.5 --- .../src/main/java/org/sonar/java/model/JParserConfig.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/java-frontend/src/main/java/org/sonar/java/model/JParserConfig.java b/java-frontend/src/main/java/org/sonar/java/model/JParserConfig.java index 0da977a8a50..cd81d499c33 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/JParserConfig.java +++ b/java-frontend/src/main/java/org/sonar/java/model/JParserConfig.java @@ -180,8 +180,10 @@ List useReleaseApiOfConfiguredJdk(Map options) { options.put(SystemReleaseClasspath.OPTION_JAVA_HOME, jdkHome.get().getAbsolutePath()); return classpath.stream().filter(f -> !f.equals(runtimeImage)).toList(); } - LOG.debug("The API of Java {} is not served from the JDK '{}': using the API of that JDK as is.", - javaVersion.effectiveJavaVersionAsString(), jdkHome.get()); + if (LOG.isDebugEnabled()) { + LOG.debug("The API of Java {} is not served from the JDK '{}': using the API of that JDK as is.", + javaVersion.effectiveJavaVersionAsString(), jdkHome.get()); + } return classpath; } }