From 6c55975525302f8a3301b84a165e92c33f6c5098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Coet?= Date: Wed, 28 Oct 2026 13:36:10 +0100 Subject: [PATCH 1/3] Do not run Spring context collection in SonarLint or in projects without Spring dependencies --- .../java/suite/SonarLintIntegrationTest.java | 2 + .../java/it/spring/SpringTelemetryTest.java | 12 +++ .../no-spring-dependencies/app/pom.xml | 11 +++ .../app/src/main/java/example/Service.java | 4 + .../resources/no-spring-dependencies/pom.xml | 15 +++ .../SpringContextGatheringModel.java | 12 +++ .../SpringContextModelGatherer.java | 6 +- .../SpringContextModelGatherers.java | 11 +++ ...gContextGatheringModelTypeAdapterTest.java | 4 + .../org/sonar/plugins/java/JavaPlugin.java | 2 +- .../org/sonar/plugins/java/JavaSensor.java | 29 +++--- .../java/SpringContextModelSensor.java | 4 + .../sonar/plugins/java/JavaPluginTest.java | 9 +- .../sonar/plugins/java/JavaSensorTest.java | 91 ++++++++++++++++++- .../java/SpringContextModelSensorTest.java | 87 +++++++++++++++--- 15 files changed, 259 insertions(+), 40 deletions(-) create mode 100644 its/scanner-integration-tests/src/test/resources/no-spring-dependencies/app/pom.xml create mode 100644 its/scanner-integration-tests/src/test/resources/no-spring-dependencies/app/src/main/java/example/Service.java create mode 100644 its/scanner-integration-tests/src/test/resources/no-spring-dependencies/pom.xml diff --git a/its/plugin/tests/src/test/java/com/sonar/it/java/suite/SonarLintIntegrationTest.java b/its/plugin/tests/src/test/java/com/sonar/it/java/suite/SonarLintIntegrationTest.java index 557cd4a69c3..e89d9804cec 100644 --- a/its/plugin/tests/src/test/java/com/sonar/it/java/suite/SonarLintIntegrationTest.java +++ b/its/plugin/tests/src/test/java/com/sonar/it/java/suite/SonarLintIntegrationTest.java @@ -71,6 +71,8 @@ public void foo() { List issues = analyzeFileAndGetIssues(inputFileDto.getUri()); + assertThat(baseDir.resolve(".sonar/spring-context-model.json")).doesNotExist(); + assertThat(issues) .extracting( RaisedIssueDto::getRuleKey, diff --git a/its/scanner-integration-tests/src/test/java/org/sonar/java/it/spring/SpringTelemetryTest.java b/its/scanner-integration-tests/src/test/java/org/sonar/java/it/spring/SpringTelemetryTest.java index 84687af83c9..b2e884edf04 100644 --- a/its/scanner-integration-tests/src/test/java/org/sonar/java/it/spring/SpringTelemetryTest.java +++ b/its/scanner-integration-tests/src/test/java/org/sonar/java/it/spring/SpringTelemetryTest.java @@ -91,6 +91,8 @@ class SpringTelemetryTest extends ScannerIntegrationAbstractTest { void spring_context_model_is_reported_as_telemetry() { var project = analyzeProject(Path.of("ambiguous-dependencies-should-be-resolved"), "S9352").getProject(); + assertThat(projectBaseDir.resolve(".sonar/spring-context-model.json")).exists(); + assertThat(telemetryValue(project, "java.spring.bean_count")) .isEqualTo(Integer.toString(EXPECTED_BEAN_COUNT)); assertThat(telemetryValue(project, "java.spring.bean_name_count")) @@ -109,6 +111,16 @@ void spring_context_model_is_reported_as_telemetry() { .isLessThan(MAXIMUM_EXPECTED_CONTEXT_MODEL_SIZE_BYTES); } + @Test + void spring_context_is_not_collected_or_saved_without_spring_dependencies() { + var project = analyzeProject(Path.of("no-spring-dependencies"), "S9352").getProject(); + + assertThat(projectBaseDir.resolve(".sonar/spring-context-model.json")).doesNotExist(); + assertThat(project.getTelemetryEntry("java.spring.bean_count")).isNull(); + assertThat(project.getTelemetryEntry("java.spring.context_model_gathering_time_ms")).isNull(); + assertThat(project.getTelemetryEntry("java.spring.context_checks_time_ms")).isNull(); + } + private static String telemetryValue(ScannerOutputReader.AnalyzedProject project, String key) { ScannerOutputReader.TelemetryEntry entry = project.getTelemetryEntry(key); assertThat(entry).as("telemetry entry %s", key).isNotNull(); diff --git a/its/scanner-integration-tests/src/test/resources/no-spring-dependencies/app/pom.xml b/its/scanner-integration-tests/src/test/resources/no-spring-dependencies/app/pom.xml new file mode 100644 index 00000000000..6684aa6cf1e --- /dev/null +++ b/its/scanner-integration-tests/src/test/resources/no-spring-dependencies/app/pom.xml @@ -0,0 +1,11 @@ + + + 4.0.0 + + com.example + no-spring-dependencies + 1.0.0 + + app + diff --git a/its/scanner-integration-tests/src/test/resources/no-spring-dependencies/app/src/main/java/example/Service.java b/its/scanner-integration-tests/src/test/resources/no-spring-dependencies/app/src/main/java/example/Service.java new file mode 100644 index 00000000000..e4f164aa971 --- /dev/null +++ b/its/scanner-integration-tests/src/test/resources/no-spring-dependencies/app/src/main/java/example/Service.java @@ -0,0 +1,4 @@ +package example; + +public class Service { +} diff --git a/its/scanner-integration-tests/src/test/resources/no-spring-dependencies/pom.xml b/its/scanner-integration-tests/src/test/resources/no-spring-dependencies/pom.xml new file mode 100644 index 00000000000..356936cfef0 --- /dev/null +++ b/its/scanner-integration-tests/src/test/resources/no-spring-dependencies/pom.xml @@ -0,0 +1,15 @@ + + + 4.0.0 + com.example + no-spring-dependencies + 1.0.0 + pom + + app + + + 17 + + diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextGatheringModel.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextGatheringModel.java index 3c6edc273f8..46ff96de035 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextGatheringModel.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextGatheringModel.java @@ -39,6 +39,9 @@ *

The structure is serialized to JSON and saved to a file at the end of the analysis, so it can be restored and * reused in later analyses when {@code sonar.java.springContext.model.path} is explicitly set. Restored data for * unvisited files is retained to provide whole-project context during partial analyses.

+ * + *

Collection is enabled when Spring dependencies are detected in any analyzed module outside SonarLint. + * This state belongs to the current analysis and is neither persisted nor restored.

*/ @ScannerSide @SonarLintSide @@ -76,6 +79,15 @@ public Set packages() { */ private final Map> filesData = new HashMap<>(); private boolean restored; + private boolean enabled; + + public boolean isEnabled() { + return enabled; + } + + public void enable() { + enabled = true; + } /** * Returns the file data indexed by module key and file key. diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextModelGatherer.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextModelGatherer.java index 0eb2891efeb..e997057041b 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextModelGatherer.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextModelGatherer.java @@ -78,11 +78,7 @@ protected SpringContextModelGatherer(SpringContextGatheringModel springContextGa @Override public boolean isCompatibleWithDependencies(Function> dependencyFinder) { - return dependencyFinder.apply("spring-context") - .or(() -> dependencyFinder.apply("spring-beans")) - .or(() -> dependencyFinder.apply("spring-boot-starter")) - .or(() -> dependencyFinder.apply("spring-boot-starter-web")) - .isPresent(); + return SpringContextModelGatherers.hasSpringDependency(dependencyFinder); } @Override diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextModelGatherers.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextModelGatherers.java index fbb404a1ad5..40d60a3946f 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextModelGatherers.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextModelGatherers.java @@ -17,7 +17,10 @@ package org.sonar.java.model.springcontext; import java.util.List; +import java.util.Optional; +import java.util.function.Function; import org.sonar.java.telemetry.Telemetry; +import org.sonar.plugins.java.api.Version; /** * Registry of all {@link SpringContextModelGatherer} visitors that collect data for the {@link SpringContextModel} @@ -32,6 +35,14 @@ private SpringContextModelGatherers() { // utility class, should not be instantiated } + public static boolean hasSpringDependency(Function> dependencyFinder) { + return dependencyFinder.apply("spring-context") + .or(() -> dependencyFinder.apply("spring-beans")) + .or(() -> dependencyFinder.apply("spring-boot-starter")) + .or(() -> dependencyFinder.apply("spring-boot-starter-web")) + .isPresent(); + } + /** * Returns all gatherers that contribute data to the {@link SpringContextModel}. * diff --git a/java-frontend/src/test/java/org/sonar/java/serialization/SpringContextGatheringModelTypeAdapterTest.java b/java-frontend/src/test/java/org/sonar/java/serialization/SpringContextGatheringModelTypeAdapterTest.java index d72e4d021a3..5264a0613c2 100644 --- a/java-frontend/src/test/java/org/sonar/java/serialization/SpringContextGatheringModelTypeAdapterTest.java +++ b/java-frontend/src/test/java/org/sonar/java/serialization/SpringContextGatheringModelTypeAdapterTest.java @@ -44,6 +44,7 @@ void round_trips_module_and_file_data_without_serializing_input_files() { var bean = new BeanDefinitionHolder.InputFileData("bean", "example.Bean", BeanDefinitionKind.STEREOTYPE, "example", new TextSpan(1, 0, 1, 4), false, ProfileExpression.UNCONDITIONAL, null, Map.of(), Set.of("example.Bean")); var model = new SpringContextGatheringModel(); + model.enable(); model.collectBeans("module-a", "file-a", INPUT_FILE, List.of(bean)); model.collectPackages("module-a", "file-a", INPUT_FILE, Set.of("example")); model.collectBeans("module-a", "file-b", INPUT_FILE, List.of()); @@ -51,6 +52,8 @@ void round_trips_module_and_file_data_without_serializing_input_files() { String json = GSON.toJson(model); + assertThat(JsonParser.parseString(json).getAsJsonObject().keySet()).containsExactly("filesData"); + var filesData = JsonParser.parseString(json).getAsJsonObject().getAsJsonObject("filesData"); assertThat(filesData.keySet()).containsExactlyInAnyOrder("module-a", "module-b"); var file = filesData.getAsJsonObject("module-a").getAsJsonObject("file-a"); @@ -59,6 +62,7 @@ void round_trips_module_and_file_data_without_serializing_input_files() { assertThat(file.getAsJsonArray("packages").get(0).getAsString()).isEqualTo("example"); var restored = GSON.fromJson(json, SpringContextGatheringModel.class); + assertThat(restored.isEnabled()).isFalse(); assertThat(restored.filesData().get("module-a")).containsOnlyKeys("file-a", "file-b"); assertThat(restored.filesData().get("module-b")).containsOnlyKeys("file-c"); assertThat(restored.filesData().get("module-a").get("file-a").beans()).containsExactly(bean); diff --git a/sonar-java-plugin/src/main/java/org/sonar/plugins/java/JavaPlugin.java b/sonar-java-plugin/src/main/java/org/sonar/plugins/java/JavaPlugin.java index 77d3c39010e..848a8b32af5 100644 --- a/sonar-java-plugin/src/main/java/org/sonar/plugins/java/JavaPlugin.java +++ b/sonar-java-plugin/src/main/java/org/sonar/plugins/java/JavaPlugin.java @@ -58,6 +58,7 @@ public void define(Context context) { } else { list.add(DefaultTelemetry.class); list.add(ProjectEndOfAnalysisSensor.class); + list.add(SpringContextModelSensor.class); list.addAll(SurefireExtensions.getExtensions()); list.add(DroppedPropertiesSensor.class); list.add(JavaSonarWayProfile.class); @@ -125,7 +126,6 @@ public void define(Context context) { .defaultValue("False") .build(), JavaSensor.class, - SpringContextModelSensor.class, PostAnalysisIssueFilter.class)); list.add(AnalysisWarningsWrapper.class); diff --git a/sonar-java-plugin/src/main/java/org/sonar/plugins/java/JavaSensor.java b/sonar-java-plugin/src/main/java/org/sonar/plugins/java/JavaSensor.java index 1c22a2bdb08..1c7f63d5757 100644 --- a/sonar-java-plugin/src/main/java/org/sonar/plugins/java/JavaSensor.java +++ b/sonar-java-plugin/src/main/java/org/sonar/plugins/java/JavaSensor.java @@ -17,6 +17,7 @@ package org.sonar.plugins.java; import java.io.File; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashSet; @@ -34,11 +35,11 @@ import org.sonar.api.batch.sensor.SensorDescriptor; import org.sonar.api.issue.NoSonarFilter; import org.sonar.api.rule.RuleKey; -import org.sonar.api.rule.RuleScope; import org.sonar.java.GeneratedCheckList; import org.sonar.java.JavaFrontend; import org.sonar.java.Measurer; import org.sonar.java.SonarComponents; +import org.sonar.java.classpath.DependencyVersionInference; import org.sonar.java.filters.PostAnalysisIssueFilter; import org.sonar.java.jsp.Jasper; import org.sonar.java.model.GeneratedFile; @@ -91,11 +92,6 @@ public JavaSensor(SonarComponents sonarComponents, JavaResourceLocator javaResou this.springContextGatheringModel = springContextGatheringModel; this.sonarComponents.registerMainChecks(GeneratedCheckList.REPOSITORY_KEY, GeneratedCheckList.getJavaChecks()); this.sonarComponents.registerTestChecks(GeneratedCheckList.REPOSITORY_KEY, GeneratedCheckList.getJavaTestChecks()); - // Main scope only: the Spring context model describes the application context, which never contains the beans - // declared by test code. Gatherers registered for RuleScope.TEST would be handed exactly the files - // JavaFileTypeClassifier already excludes, and collect nothing. - SpringContextModelGatherers.getAllGatherers(springContextGatheringModel, telemetry) - .forEach(gatherer -> this.sonarComponents.registerCustomFileScanner(RuleScope.MAIN, gatherer)); } @Override @@ -108,13 +104,20 @@ public void execute(SensorContext context) { PerformanceMeasure.Duration sensorDuration = createPerformanceMeasureReport(context); sonarComponents.setSensorContext(context); - // Restore the Spring context gathering model from the previous analysis, if it hasn't been restored already - // (i.e. the model is only restored the first time the sensor is run on a module and is reused between modules). - if (context.config().hasKey(SPRING_CONTEXT_MODEL_PATH_PROPERTY) && !springContextGatheringModel.isRestored()) { - var path = SpringContextModelPersistence.modelPath(context, sonarComponents.projectLevelBaseDir()); - springContextGatheringModel.restoreFrom(SpringContextModelPersistence.load(path)); - } sonarComponents.setCheckFilter(createCheckFilter(sonarComponents.isAutoScanCheckFiltering())); + List mainChecks = new ArrayList<>(sonarComponents.mainChecks()); + if (!sonarComponents.isSonarLintContext()) { + List classpath = sonarComponents.getJavaClasspath(); + var dependencyService = new DependencyVersionInference(); + if (SpringContextModelGatherers.hasSpringDependency(artifactId -> dependencyService.infer(artifactId, classpath))) { + springContextGatheringModel.enable(); + if (context.config().hasKey(SPRING_CONTEXT_MODEL_PATH_PROPERTY) && !springContextGatheringModel.isRestored()) { + var path = SpringContextModelPersistence.modelPath(context, sonarComponents.projectLevelBaseDir()); + springContextGatheringModel.restoreFrom(SpringContextModelPersistence.load(path)); + } + mainChecks.addAll(SpringContextModelGatherers.getAllGatherers(springContextGatheringModel, telemetry)); + } + } Measurer measurer = new Measurer(context, noSonarFilter); @@ -131,7 +134,7 @@ public void execute(SensorContext context) { telemetry, javaResourceLocator, postAnalysisIssueFilter, - sonarComponents.mainChecks().toArray(new JavaCheck[0])); + mainChecks.toArray(new JavaCheck[0])); frontend.scan(getSourceFiles(context.fileSystem()), getTestFiles(context.fileSystem()), runJasper(context)); sensorDuration.stop(); diff --git a/sonar-java-plugin/src/main/java/org/sonar/plugins/java/SpringContextModelSensor.java b/sonar-java-plugin/src/main/java/org/sonar/plugins/java/SpringContextModelSensor.java index bc54ef0fbf9..d2288ddd6e9 100644 --- a/sonar-java-plugin/src/main/java/org/sonar/plugins/java/SpringContextModelSensor.java +++ b/sonar-java-plugin/src/main/java/org/sonar/plugins/java/SpringContextModelSensor.java @@ -17,6 +17,7 @@ package org.sonar.plugins.java; import java.util.concurrent.TimeUnit; +import org.sonar.api.SonarProduct; import org.sonar.api.batch.DependedUpon; import org.sonar.api.batch.Phase; import org.sonar.api.batch.sensor.SensorContext; @@ -72,6 +73,9 @@ public void describe(SensorDescriptor descriptor) { */ @Override public void execute(SensorContext context) { + if (context.runtime().getProduct() == SonarProduct.SONARLINT || !gatheringModel.isEnabled()) { + return; + } long buildingStartTime = System.nanoTime(); SpringContextModel springContextModel = SpringContextModel.of(gatheringModel); telemetry.aggregateAsCounter( diff --git a/sonar-java-plugin/src/test/java/org/sonar/plugins/java/JavaPluginTest.java b/sonar-java-plugin/src/test/java/org/sonar/plugins/java/JavaPluginTest.java index b1318e3f56e..b371b31b74b 100644 --- a/sonar-java-plugin/src/test/java/org/sonar/plugins/java/JavaPluginTest.java +++ b/sonar-java-plugin/src/test/java/org/sonar/plugins/java/JavaPluginTest.java @@ -41,8 +41,9 @@ void sonarLint_9_9_extensions() { Plugin.Context context = new Plugin.Context(runtime); javaPlugin.define(context); assertThat(context.getExtensions()) - .hasSize(23) - .contains(SonarLintCache.class, SpringContextGatheringModel.class); + .hasSize(22) + .contains(SonarLintCache.class, SpringContextGatheringModel.class) + .doesNotContain(SpringContextModelSensor.class); } @@ -53,7 +54,7 @@ void sonarqube_9_9_extensions() { javaPlugin.define(context); assertThat(context.getExtensions()) .hasSize(40) - .contains(SpringContextGatheringModel.class) + .contains(SpringContextGatheringModel.class, SpringContextModelSensor.class) .doesNotContain(Jasper.class); } @@ -64,7 +65,7 @@ void sonarqube_9_9_commercial_extensions() { javaPlugin.define(context); assertThat(context.getExtensions()) .hasSize(41) - .contains(Jasper.class, SpringContextGatheringModel.class); + .contains(Jasper.class, SpringContextGatheringModel.class, SpringContextModelSensor.class); } } diff --git a/sonar-java-plugin/src/test/java/org/sonar/plugins/java/JavaSensorTest.java b/sonar-java-plugin/src/test/java/org/sonar/plugins/java/JavaSensorTest.java index 7ecae776712..41b261eab63 100644 --- a/sonar-java-plugin/src/test/java/org/sonar/plugins/java/JavaSensorTest.java +++ b/sonar-java-plugin/src/test/java/org/sonar/plugins/java/JavaSensorTest.java @@ -17,6 +17,7 @@ package org.sonar.plugins.java; import com.sonarsource.scanner.engine.sensor.test.fixtures.SensorContextTester; +import com.sonarsource.scanner.engine.sensor.test.fixtures.TestFileSystem; import com.sonarsource.scanner.engine.sensor.test.fixtures.TestInputFileBuilder; import com.sonarsource.scanner.engine.sensor.test.fixtures.TestSonarRuntime; import java.io.File; @@ -33,6 +34,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.slf4j.event.Level; import org.sonar.api.SonarEdition; @@ -66,6 +69,7 @@ import org.sonar.java.reporting.AnalyzerMessage; import org.sonar.java.telemetry.DefaultTelemetry; import org.sonar.java.telemetry.Telemetry; +import org.sonar.java.test.classpath.TestClasspathUtils; import org.sonar.plugins.java.api.CheckRegistrar; import org.sonar.plugins.java.api.JavaCheck; import org.sonar.plugins.java.api.JavaFileScanner; @@ -74,7 +78,6 @@ import org.sonar.plugins.java.api.JavaVersion; import org.sonar.plugins.java.api.JspCodeVisitor; import org.sonar.scanner.plugin.api.impl.config.MapSettings; -import com.sonarsource.scanner.engine.sensor.test.fixtures.TestFileSystem; import org.sonar.scanner.plugin.api.impl.fs.DefaultInputFile; import org.sonar.scanner.plugin.api.impl.rule.ActiveRulesBuilder; import org.sonar.scanner.plugin.api.impl.rule.NewActiveRule; @@ -96,7 +99,7 @@ class JavaSensorTest { - private static final String EXPECTED_TYPE_ERROR_COUNT = "214"; + private static final String EXPECTED_TYPE_ERROR_COUNT = "223"; private static final String SPRING_CONTEXT_MODEL_PATH = "state/context.json"; private static final String SPRING_CONTEXT_FIXTURE_DIRECTORY = "/org/sonar/plugins/java/springcontext/"; private static final CheckFactory checkFactory = mock(CheckFactory.class); @@ -171,6 +174,84 @@ void restores_SpringContextGatheringModel_only_once() throws IOException { assertThat(gatheringModel.filesData().get("previous-module")).containsOnlyKeys("previous-file"); } + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void skips_spring_context_restoration_without_spring_or_in_sonarlint(boolean sonarLint) throws IOException { + copySpringContextFixture("unvisited-service.json"); + Path modelPath = tmp.resolve(SPRING_CONTEXT_MODEL_PATH); + String previousContent = Files.readString(modelPath); + SensorContextTester context = springSensorContext(); + if (sonarLint) { + context.setRuntime(TestSonarRuntime.forSonarLint(Version.create(6, 7))); + } + SonarComponents components = createSonarComponentsMock(context); + if (sonarLint) { + when(components.getJavaClasspath()).thenReturn(TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + } + var gatheringModel = new SpringContextGatheringModel(); + + var javaResourceLocator = mock(JavaResourceLocator.class); + var noSonarFilter = mock(NoSonarFilter.class); + new JavaSensor(components, javaResourceLocator, noSonarFilter, null, telemetry, gatheringModel).execute(context); + + assertThat(gatheringModel.isRestored()).isFalse(); + assertThat(gatheringModel.isEnabled()).isFalse(); + assertThat(gatheringModel.filesData()).isEmpty(); + assertThat(Files.readString(modelPath)).isEqualTo(previousContent); + assertThat(telemetry.toMap().keySet()).noneMatch(key -> key.startsWith("java.spring.")); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void enables_spring_context_only_for_spring_modules_in_either_order(boolean springFirst) throws IOException { + copySpringContextFixture("unvisited-service.json"); + SensorContextTester context = springSensorContext(); + SonarComponents components = createSonarComponentsMock(context); + var gatheringModel = new SpringContextGatheringModel(); + var sensor = new JavaSensor(components, mock(JavaResourceLocator.class), mock(NoSonarFilter.class), null, telemetry, gatheringModel); + + for (boolean spring : List.of(springFirst, !springFirst)) { + when(components.getJavaClasspath()).thenReturn(spring ? TestClasspathUtils.DEFAULT_MODULE.getClassPath() : List.of()); + sensor.execute(context); + + assertThat(gatheringModel.isEnabled()).isEqualTo(spring || springFirst); + assertThat(gatheringModel.isRestored()).isEqualTo(spring || springFirst); + } + assertThat(gatheringModel.filesData().get("module")).containsOnlyKeys("unvisited"); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void collects_spring_context_only_outside_sonarlint(boolean sonarLint) throws IOException { + SensorContextTester context = springSensorContext(); + if (sonarLint) { + context.setRuntime(TestSonarRuntime.forSonarLint(Version.create(6, 7))); + } + String contents = "@org.springframework.stereotype.Component class Service {}"; + Files.writeString(tmp.resolve("Service.java"), contents); + InputFile inputFile = new TestInputFileBuilder("module", "Service.java") + .setModuleBaseDir(tmp).setLanguage("java").setType(InputFile.Type.MAIN).setContents(contents).setCharset(UTF_8).build(); + context.fileSystem().add(inputFile); + SonarComponents components = createSonarComponentsMock(context); + when(components.getJavaClasspath()).thenReturn(TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var gatheringModel = new SpringContextGatheringModel(); + var sensor = new JavaSensor(components, mock(JavaResourceLocator.class), mock(NoSonarFilter.class), null, telemetry, gatheringModel); + + sensor.execute(context); + sensor.execute(context); + + assertThat(gatheringModel.isEnabled()).isEqualTo(!sonarLint); + if (sonarLint) { + assertThat(gatheringModel.filesData()).isEmpty(); + assertThat(telemetry.toMap().keySet()).noneMatch(key -> key.startsWith("java.spring.")); + } else { + assertThat(gatheringModel.filesData().values()).hasSize(1); + var fileData = gatheringModel.filesData().values().iterator().next().get(inputFile.key()); + assertThat(fileData.beans()).extracting(BeanDefinitionHolder.InputFileData::beanName).containsExactly("service"); + assertThat(telemetry.toMap()).containsKey("java.spring.context_model_gathering_time_ms"); + } + } + private SensorContextTester springSensorContext() { SensorContextTester context = SensorContextTester.create(tmp); context.fileSystem().setWorkDir(tmp); @@ -179,7 +260,9 @@ private SensorContextTester springSensorContext() { } private JavaSensor javaSensor(SensorContextTester context, SpringContextGatheringModel gatheringModel) { - return new JavaSensor(createSonarComponentsMock(context), mock(JavaResourceLocator.class), mock(NoSonarFilter.class), null, telemetry, gatheringModel); + SonarComponents components = createSonarComponentsMock(context); + when(components.getJavaClasspath()).thenReturn(TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + return new JavaSensor(components, mock(JavaResourceLocator.class), mock(NoSonarFilter.class), null, telemetry, gatheringModel); } private void copySpringContextFixture(String fixtureName) throws IOException { @@ -192,7 +275,7 @@ private void copySpringContextFixture(String fixtureName) throws IOException { @Test void test_issues_creation_on_main_file() throws IOException { - testIssueCreation(InputFile.Type.MAIN, 19); + testIssueCreation(InputFile.Type.MAIN, 22); Map telemetryMap = telemetry.toMap(); assertThat(telemetryMap).containsOnlyKeys( diff --git a/sonar-java-plugin/src/test/java/org/sonar/plugins/java/SpringContextModelSensorTest.java b/sonar-java-plugin/src/test/java/org/sonar/plugins/java/SpringContextModelSensorTest.java index c9d92775f63..4a2b15fbf2e 100644 --- a/sonar-java-plugin/src/test/java/org/sonar/plugins/java/SpringContextModelSensorTest.java +++ b/sonar-java-plugin/src/test/java/org/sonar/plugins/java/SpringContextModelSensorTest.java @@ -18,6 +18,8 @@ import com.sonarsource.scanner.engine.sensor.test.fixtures.SensorContextTester; import com.sonarsource.scanner.engine.sensor.test.fixtures.TestInputFileBuilder; +import com.sonarsource.scanner.engine.sensor.test.fixtures.TestSonarRuntime; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; @@ -29,6 +31,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.slf4j.event.Level; import org.sonar.api.batch.bootstrap.ProjectDefinition; import org.sonar.api.batch.fs.InputFile; @@ -74,10 +78,65 @@ class SpringContextModelSensorTest { @RegisterExtension public LogTesterJUnit5 logTester = new LogTesterJUnit5(); + @ParameterizedTest + @ValueSource(strings = {"", "state/context.json"}) + void skips_finalization_without_spring_dependencies(String configuredPath) throws IOException { + SensorContextTester context = javaSensorContext(tempDir, configuredPath); + context.setActiveRules(activeRulesWith(S4605_RULE_KEY)); + Path modelPath = tempDir.resolve(configuredPath.isEmpty() ? DEFAULT_MODEL_PATH : configuredPath); + var gatheringModel = new SpringContextGatheringModel(); + registerBean(gatheringModel, "example.Service", "service", fakeInputFile(context, "Service.java")); + var telemetry = new DefaultTelemetry(); + var sensor = new SpringContextModelSensor(telemetry, gatheringModel); + + sensor.execute(context); + + assertThat(modelPath.getParent()).doesNotExist(); + assertThat(context.allIssues()).isEmpty(); + assertThat(telemetry.toMap()).isEmpty(); + Files.createDirectories(modelPath.getParent()); + Files.writeString(modelPath, "previous model"); + + sensor.execute(context); + + assertThat(Files.readString(modelPath)).isEqualTo("previous model"); + assertThat(logTester.logs(Level.DEBUG)).noneMatch(message -> message.contains("Saving Spring Context model")); + } + + @Test + void skips_finalization_in_sonarlint() { + SensorContextTester context = javaSensorContext(tempDir); + context.setRuntime(TestSonarRuntime.forSonarLint(org.sonar.api.utils.Version.create(6, 7))); + context.setActiveRules(activeRulesWith(S4605_RULE_KEY)); + var gatheringModel = new SpringContextGatheringModel(); + gatheringModel.enable(); + registerBean(gatheringModel, "example.Service", "service", fakeInputFile(context, "Service.java")); + var telemetry = new DefaultTelemetry(); + + new SpringContextModelSensor(telemetry, gatheringModel).execute(context); + + assertThat(tempDir.resolve(".sonar")).doesNotExist(); + assertThat(context.allIssues()).isEmpty(); + assertThat(telemetry.toMap()).isEmpty(); + } + + @Test + void finalizes_an_empty_context_when_spring_dependencies_were_detected() { + SensorContextTester context = javaSensorContext(tempDir); + var gatheringModel = new SpringContextGatheringModel(); + gatheringModel.enable(); + var telemetry = new DefaultTelemetry(); + + new SpringContextModelSensor(telemetry, gatheringModel).execute(context); + + assertThat(tempDir.resolve(DEFAULT_MODEL_PATH)).exists(); + assertThat(telemetry.toMap()).containsEntry("java.spring.bean_count", "0"); + } + @Test void test_toString() { DefaultSensorDescriptor descriptor = new DefaultSensorDescriptor(); - SpringContextModelSensor sensor = sensor(new SpringContextGatheringModel(), new NoOpTelemetry()); + SpringContextModelSensor sensor = enabledSensor(new SpringContextGatheringModel(), new NoOpTelemetry()); sensor.describe(descriptor); assertThat(descriptor.name()).isEqualTo("Java SpringContextModelSensor"); assertThat(descriptor.languages()).containsExactly("java", "jsp"); @@ -96,7 +155,7 @@ void reports_an_issue_for_an_ambiguous_dependency() { registerDependency(gatheringModel, type, "contextAware", inputFile); var telemetry = new DefaultTelemetry(); - sensor(gatheringModel, telemetry).execute(context); + enabledSensor(gatheringModel, telemetry).execute(context); assertThat(context.allIssues()).hasSize(1); Issue issue = context.allIssues().iterator().next(); @@ -117,7 +176,7 @@ void reports_an_issue_for_an_uncovered_spring_bean() { registerBean(gatheringModel, "example.MyComponent", "myComponent", inputFile); - sensor(gatheringModel, new NoOpTelemetry()).execute(context); + enabledSensor(gatheringModel, new NoOpTelemetry()).execute(context); assertThat(context.allIssues()).hasSize(1); Issue issue = context.allIssues().iterator().next(); @@ -141,7 +200,7 @@ void does_not_report_issues_when_rule_is_not_active() { registerDependency(gatheringModel, type, "contextAware", inputFile); var telemetry = new DefaultTelemetry(); - sensor(gatheringModel, telemetry).execute(context); + enabledSensor(gatheringModel, telemetry).execute(context); assertThat(context.allIssues()).isEmpty(); assertThat(telemetry.toMap().get("java.spring.context_checks_time_ms")).matches("\\d+"); @@ -160,7 +219,7 @@ void restored_beans_affect_analyzed_files_in_SQAA_without_reporting_on_restored_ var dependency = Map.of(type, Set.of(new InjectionPoint.InputFileData("contextAware", new TextSpan(13, 13, 13, 25), false))); gatheringModel.collectBeans(MODULE_KEY, analyzedFile.key(), analyzedFile, List.of(gatheredBean("consumer", "example.Consumer", dependency))); - sensor(gatheringModel, new NoOpTelemetry()).execute(context); + enabledSensor(gatheringModel, new NoOpTelemetry()).execute(context); assertThat(context.allIssues()).hasSize(1); assertThat(context.allIssues().iterator().next().primaryLocation().inputComponent()).isEqualTo(analyzedFile); @@ -179,7 +238,7 @@ void reports_issues_on_analyzed_but_skipped_files_in_incremental_analysis() thro var gatheringModel = restoredModel("ambiguous-restored-beans.json"); gatheringModel.collectPackages(MODULE_KEY, "restored-consumer", skippedFile, Set.of()); - sensor(gatheringModel, new NoOpTelemetry()).execute(context); + enabledSensor(gatheringModel, new NoOpTelemetry()).execute(context); assertThat(context.allIssues()).hasSize(1); Issue issue = context.allIssues().iterator().next(); @@ -200,7 +259,7 @@ void reports_issues_when_saving_fails() throws IOException { registerBean(gatheringModel, type, "componentTwo", inputFile); registerDependency(gatheringModel, type, "contextAware", inputFile); - sensor(gatheringModel, new NoOpTelemetry()).execute(context); + enabledSensor(gatheringModel, new NoOpTelemetry()).execute(context); assertThat(context.allIssues()).hasSize(1); assertThat(logTester.logs(Level.WARN)).anyMatch(message -> message.contains("Unable to save Spring context model to ")); @@ -224,7 +283,7 @@ void records_metrics_for_the_built_model() { gatheringModel.collectPackages(MODULE_KEY, "componentOne", inputFile, Set.of("example")); var telemetry = new DefaultTelemetry(); - sensor(gatheringModel, telemetry).execute(context); + enabledSensor(gatheringModel, telemetry).execute(context); assertThat(telemetry.toMap()) .containsEntry("java.spring.bean_count", "3") @@ -249,6 +308,7 @@ void restores_updates_and_saves_one_model_across_module_contexts() throws IOExce var gatheringModel = new SpringContextGatheringModel(); JavaSensor sensor = javaSensor(gatheringModel); sensor.execute(javaSensorContext(moduleA, MODEL_PATH)); + assertThat(gatheringModel.isEnabled()).isTrue(); assertThat(gatheringModel.filesData()).containsOnlyKeys("module-a", "module-b"); var updatedBean = bean("updated"); @@ -265,7 +325,7 @@ void restores_updates_and_saves_one_model_across_module_contexts() throws IOExce assertThat(projectModel.getBeanDefinitionRegistry().getByName("untouched")).hasSize(1); assertThat(projectModel.getProjectPackageScan().getPackagesForModule("module-b")).containsExactly("untouched.package"); - sensor(gatheringModel, new NoOpTelemetry()).execute(javaSensorContext(tempDir, MODEL_PATH)); + enabledSensor(gatheringModel, new NoOpTelemetry()).execute(javaSensorContext(tempDir, MODEL_PATH)); SpringContextGatheringModel saved = SpringContextModelPersistence.load(modelPath); assertThat(saved.filesData()).containsOnlyKeys("module-a", "module-b"); @@ -292,7 +352,7 @@ void ignores_the_default_model_and_saves_current_data_when_the_spring_context_mo assertThat(gatheringModel.filesData()).isEmpty(); registerBean(gatheringModel, "example.Service", "currentService", fakeInputFile(analysisContext, "Service.java")); - sensor(gatheringModel, new NoOpTelemetry()).execute(analysisContext); + enabledSensor(gatheringModel, new NoOpTelemetry()).execute(analysisContext); var savedModel = SpringContextModelPersistence.load(defaultPath); assertThat(savedModel.filesData()).containsOnlyKeys(MODULE_KEY); @@ -322,7 +382,7 @@ void saves_to_the_default_path_when_the_spring_context_model_property_is_unset_o var gatheringModel = new SpringContextGatheringModel(); registerBean(gatheringModel, "example.Service", beanName, fakeInputFile(analysisContext, "Component.java")); - sensor(gatheringModel, new NoOpTelemetry()).execute(analysisContext); + enabledSensor(gatheringModel, new NoOpTelemetry()).execute(analysisContext); assertThat(defaultPath).exists(); assertThat(SpringContextModelPersistence.load(defaultPath).filesData().get(MODULE_KEY)).containsOnlyKeys(beanName); @@ -378,7 +438,8 @@ void saves_a_new_model_in_a_new_directory() { assertThat(SpringContextModelPersistence.load(modelPath).filesData()).isEmpty(); } - private SpringContextModelSensor sensor(SpringContextGatheringModel gatheringModel, Telemetry telemetry) { + private SpringContextModelSensor enabledSensor(SpringContextGatheringModel gatheringModel, Telemetry telemetry) { + gatheringModel.enable(); return new SpringContextModelSensor(telemetry, gatheringModel); } @@ -408,7 +469,7 @@ private JavaSensor javaSensor(SpringContextGatheringModel gatheringModel) { when(components.mainChecks()).thenReturn(List.of()); when(components.testChecks()).thenReturn(List.of()); when(components.jspChecks()).thenReturn(List.of()); - when(components.getJavaClasspath()).thenReturn(List.of()); + when(components.getJavaClasspath()).thenReturn(List.of(new File("spring-context-6.0.0.jar"))); when(components.getJavaTestClasspath()).thenReturn(List.of()); when(components.getJspClasspath()).thenReturn(List.of()); return new JavaSensor(components, mock(JavaResourceLocator.class), mock(NoSonarFilter.class), null, new NoOpTelemetry(), gatheringModel); From 81068e7590efc207ee038aba3de8c56f0844626d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Coet?= Date: Wed, 28 Oct 2026 14:04:49 +0100 Subject: [PATCH 2/3] Fix tests --- .../tests/src/test/java/com/sonar/it/java/JspTest.java | 7 ------- .../sonar/plugins/java/SpringContextModelSensorTest.java | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/its/plugin/tests/src/test/java/com/sonar/it/java/JspTest.java b/its/plugin/tests/src/test/java/com/sonar/it/java/JspTest.java index 9bf03f37759..ee3723fd23e 100644 --- a/its/plugin/tests/src/test/java/com/sonar/it/java/JspTest.java +++ b/its/plugin/tests/src/test/java/com/sonar/it/java/JspTest.java @@ -110,13 +110,6 @@ public void should_transpile_jsp() throws Exception { Telemetry java.language.version: 8 Telemetry java.module_count: 1 Telemetry java.scanner_app: ScannerMaven - Telemetry java.spring.bean_count: \\d+ - Telemetry java.spring.bean_name_count: \\d+ - Telemetry java.spring.component_scan_package_count: \\d+ - Telemetry java.spring.context_checks_time_ms: \\d+ - Telemetry java.spring.context_model_gathering_time_ms: \\d+ - Telemetry java.spring.context_model_size_bytes: \\d+ - Telemetry java.spring.injection_point_count: \\d+ """)); } } diff --git a/sonar-java-plugin/src/test/java/org/sonar/plugins/java/SpringContextModelSensorTest.java b/sonar-java-plugin/src/test/java/org/sonar/plugins/java/SpringContextModelSensorTest.java index 4a2b15fbf2e..2c99690a71a 100644 --- a/sonar-java-plugin/src/test/java/org/sonar/plugins/java/SpringContextModelSensorTest.java +++ b/sonar-java-plugin/src/test/java/org/sonar/plugins/java/SpringContextModelSensorTest.java @@ -91,7 +91,7 @@ void skips_finalization_without_spring_dependencies(String configuredPath) throw sensor.execute(context); - assertThat(modelPath.getParent()).doesNotExist(); + assertThat(modelPath).doesNotExist(); assertThat(context.allIssues()).isEmpty(); assertThat(telemetry.toMap()).isEmpty(); Files.createDirectories(modelPath.getParent()); @@ -115,7 +115,7 @@ void skips_finalization_in_sonarlint() { new SpringContextModelSensor(telemetry, gatheringModel).execute(context); - assertThat(tempDir.resolve(".sonar")).doesNotExist(); + assertThat(tempDir.resolve(DEFAULT_MODEL_PATH)).doesNotExist(); assertThat(context.allIssues()).isEmpty(); assertThat(telemetry.toMap()).isEmpty(); } From 888307b0bd8177e7b78859c3eebe49d6c1a8b3c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Coet?= Date: Wed, 28 Oct 2026 15:20:06 +0100 Subject: [PATCH 3/3] Address review comments --- .../java/org/sonar/java/it/spring/SpringTelemetryTest.java | 4 ++++ .../src/test/java/org/sonar/plugins/java/JavaSensorTest.java | 4 +--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/its/scanner-integration-tests/src/test/java/org/sonar/java/it/spring/SpringTelemetryTest.java b/its/scanner-integration-tests/src/test/java/org/sonar/java/it/spring/SpringTelemetryTest.java index b2e884edf04..96916338cec 100644 --- a/its/scanner-integration-tests/src/test/java/org/sonar/java/it/spring/SpringTelemetryTest.java +++ b/its/scanner-integration-tests/src/test/java/org/sonar/java/it/spring/SpringTelemetryTest.java @@ -18,6 +18,7 @@ import com.sonarsource.scanner.integrationtester.dsl.ScannerOutputReader; import java.nio.file.Path; +import java.util.List; import org.junit.jupiter.api.Test; import org.sonar.java.it.ScannerIntegrationAbstractTest; @@ -92,6 +93,9 @@ void spring_context_model_is_reported_as_telemetry() { var project = analyzeProject(Path.of("ambiguous-dependencies-should-be-resolved"), "S9352").getProject(); assertThat(projectBaseDir.resolve(".sonar/spring-context-model.json")).exists(); + for (String module : List.of("module-common", "module-a", "module-b", "app")) { + assertThat(projectBaseDir.resolve(module).resolve(".sonar/spring-context-model.json")).doesNotExist(); + } assertThat(telemetryValue(project, "java.spring.bean_count")) .isEqualTo(Integer.toString(EXPECTED_BEAN_COUNT)); diff --git a/sonar-java-plugin/src/test/java/org/sonar/plugins/java/JavaSensorTest.java b/sonar-java-plugin/src/test/java/org/sonar/plugins/java/JavaSensorTest.java index 41b261eab63..c034ba5b0f7 100644 --- a/sonar-java-plugin/src/test/java/org/sonar/plugins/java/JavaSensorTest.java +++ b/sonar-java-plugin/src/test/java/org/sonar/plugins/java/JavaSensorTest.java @@ -181,11 +181,9 @@ void skips_spring_context_restoration_without_spring_or_in_sonarlint(boolean son Path modelPath = tmp.resolve(SPRING_CONTEXT_MODEL_PATH); String previousContent = Files.readString(modelPath); SensorContextTester context = springSensorContext(); - if (sonarLint) { - context.setRuntime(TestSonarRuntime.forSonarLint(Version.create(6, 7))); - } SonarComponents components = createSonarComponentsMock(context); if (sonarLint) { + context.setRuntime(TestSonarRuntime.forSonarLint(Version.create(6, 7))); when(components.getJavaClasspath()).thenReturn(TestClasspathUtils.DEFAULT_MODULE.getClassPath()); } var gatheringModel = new SpringContextGatheringModel();