diff --git a/java-checks/src/test/java/org/sonar/java/checks/spring/AmbiguousDependencyCheckTest.java b/java-checks/src/test/java/org/sonar/java/checks/spring/AmbiguousDependencyCheckTest.java index bbb8fe0e511..caf195017f3 100644 --- a/java-checks/src/test/java/org/sonar/java/checks/spring/AmbiguousDependencyCheckTest.java +++ b/java-checks/src/test/java/org/sonar/java/checks/spring/AmbiguousDependencyCheckTest.java @@ -373,10 +373,8 @@ private static SpringContextModel buildModel(List filePaths) { List classpath = TestClasspathUtils.DEFAULT_MODULE.getClassPath(); SonarComponents sonarComponents = new SonarComponents(null, null, null, null, null, null); sonarComponents.setSensorContext(SensorContextTester.create(new File(""))); - SpringContextModel model = new SpringContextModel(); - sonarComponents.setSpringContextModel(model); - - BeanDefinitionGatherer gatherer = new BeanDefinitionGatherer(new SpringContextGatheringModel(), new NoOpTelemetry()); + var gatheringModel = new SpringContextGatheringModel(); + BeanDefinitionGatherer gatherer = new BeanDefinitionGatherer(gatheringModel, new NoOpTelemetry()); VisitorsBridge visitorsBridge = new VisitorsBridge(List.of((JavaCheck) gatherer), classpath, sonarComponents); for (String filePath : filePaths) { File file = new File(filePath); @@ -385,7 +383,7 @@ private static SpringContextModel buildModel(List filePaths) { visitorsBridge.visitFile(compilationUnit, false); } visitorsBridge.endOfAnalysis(); - return model; + return SpringContextModel.of(gatheringModel); } private static InputFile inputFile(File file) { diff --git a/java-frontend/src/main/java/org/sonar/java/JavaFrontend.java b/java-frontend/src/main/java/org/sonar/java/JavaFrontend.java index 2eb9106a2d9..09c44fc0ac2 100644 --- a/java-frontend/src/main/java/org/sonar/java/JavaFrontend.java +++ b/java-frontend/src/main/java/org/sonar/java/JavaFrontend.java @@ -42,8 +42,6 @@ import org.sonar.java.filters.SonarJavaIssueFilter; import org.sonar.java.model.JParserConfig; import org.sonar.java.model.VisitorsBridge; -import org.sonar.java.model.springcontext.SpringContextGatheringModel; -import org.sonar.java.model.springcontext.SpringContextModelGatherers; import org.sonar.java.telemetry.Telemetry; import org.sonar.java.telemetry.TelemetryKey; import org.sonar.plugins.java.api.JavaCheck; @@ -61,7 +59,9 @@ public class JavaFrontend { private static final Logger LOG = LoggerFactory.getLogger(JavaFrontend.class); private static final String BATCH_ERROR_MESSAGE = "Batch Mode failed, analysis of Java Files stopped."; - /** List of libraries, whose presence or absence we want to report. */ + /** + * List of libraries, whose presence or absence we want to report. + */ private static final Map REPORTED_DEPENDENCIES = Map.of( JAVA_DEPENDENCY_LOMBOK, "lombok", JAVA_DEPENDENCY_SPRING_BOOT, "spring-boot", @@ -77,7 +77,7 @@ public class JavaFrontend { private final JavaAstScanner astScannerForGeneratedFiles; public JavaFrontend(JavaVersion javaVersion, SonarComponents sonarComponents, Measurer measurer, Telemetry telemetry, - JavaResourceLocator javaResourceLocator, @Nullable SonarJavaIssueFilter postAnalysisIssueFilter, JavaCheck... visitors) { + JavaResourceLocator javaResourceLocator, @Nullable SonarJavaIssueFilter postAnalysisIssueFilter, JavaCheck... visitors) { this.javaVersion = javaVersion; this.sonarComponents = sonarComponents; this.telemetry = telemetry; @@ -96,13 +96,6 @@ public JavaFrontend(JavaVersion javaVersion, SonarComponents sonarComponents, Me List testCodeVisitors = new ArrayList<>(commonVisitors); testCodeVisitors.add(measurer.new TestFileMeasurer()); - if (sonarComponents.getSpringContextModel() != null) { - // Call SpringContextModelGatherers.getAllGatherers twice to have separate gatherer - // instances between the main and test scanners to avoid duplicating the issues - codeVisitors.addAll(SpringContextModelGatherers.getAllGatherers(new SpringContextGatheringModel(), telemetry)); - testCodeVisitors.addAll(SpringContextModelGatherers.getAllGatherers(new SpringContextGatheringModel(), telemetry)); - } - if (!sonarComponents.isSonarLintContext()) { codeVisitors.add(new FileLinesVisitor(sonarComponents)); codeVisitors.add(new SyntaxHighlighterVisitor(sonarComponents)); diff --git a/java-frontend/src/main/java/org/sonar/java/SonarComponents.java b/java-frontend/src/main/java/org/sonar/java/SonarComponents.java index a3c0cf18920..e57da7ea757 100644 --- a/java-frontend/src/main/java/org/sonar/java/SonarComponents.java +++ b/java-frontend/src/main/java/org/sonar/java/SonarComponents.java @@ -66,7 +66,6 @@ import org.sonar.java.model.GeneratedFile; import org.sonar.java.model.JProblem; import org.sonar.java.model.LineUtils; -import org.sonar.java.model.springcontext.SpringContextModel; import org.sonar.java.reporting.AnalyzerMessage; import org.sonar.java.reporting.JavaIssue; import org.sonar.java.utils.ModuleMetadataUtils; @@ -137,7 +136,6 @@ public class SonarComponents extends CheckRegistrar.RegistrarContext { private SensorContext context; private UnaryOperator> checkFilter = UnaryOperator.identity(); private final Set additionalAutoScanCompatibleRuleKeys; - private SpringContextModel springContextModel; private boolean alreadyLoggedSkipStatus = false; @@ -524,6 +522,11 @@ public File projectLevelWorkDir() { } } + public File projectLevelBaseDir() { + var root = ModuleMetadataUtils.getRootProject(projectDefinition); + return root != null ? root.getBaseDir() : fs.baseDir(); + } + /** * Returns an OS-independent key that should identify the module within the project * @@ -545,23 +548,26 @@ public Optional getFullyQualifiedModuleKey() { public boolean canSkipUnchangedFiles() throws ApiMismatchException { if (context == null) { return false; - } else { - var overrideSkipFlag = context.config() == null ? null : context.config().getBoolean(SONAR_CAN_SKIP_UNCHANGED_FILES_KEY).orElse(null); - try { - if (overrideSkipFlag != null) { - return overrideSkipFlag; - } - Method canSkipUnchangedFiles = context.getClass().getMethod("canSkipUnchangedFiles"); - return (Boolean) canSkipUnchangedFiles.invoke(context); - } catch (NoSuchMethodError | NoSuchMethodException error) { - throw new ApiMismatchException(error); - } catch (InvocationTargetException | IllegalAccessException error) { - Throwable cause = error.getCause(); - if (cause instanceof NoSuchMethodError) { - throw new ApiMismatchException(cause); - } - throw new ApiMismatchException(error); + } + return canSkipUnchangedFiles(context); + } + + public static boolean canSkipUnchangedFiles(SensorContext context) throws ApiMismatchException { + var overrideSkipFlag = context.config() == null ? null : context.config().getBoolean(SONAR_CAN_SKIP_UNCHANGED_FILES_KEY).orElse(null); + try { + if (overrideSkipFlag != null) { + return overrideSkipFlag; } + Method canSkipUnchangedFiles = context.getClass().getMethod("canSkipUnchangedFiles"); + return (Boolean) canSkipUnchangedFiles.invoke(context); + } catch (NoSuchMethodError | NoSuchMethodException error) { + throw new ApiMismatchException(error); + } catch (InvocationTargetException | IllegalAccessException error) { + Throwable cause = error.getCause(); + if (cause instanceof NoSuchMethodError) { + throw new ApiMismatchException(cause); + } + throw new ApiMismatchException(error); } } @@ -694,12 +700,4 @@ public Configuration getConfiguration() { return context.config(); } - public void setSpringContextModel(SpringContextModel springContextModel) { - this.springContextModel = springContextModel; - } - - public SpringContextModel getSpringContextModel() { - return springContextModel; - } - } diff --git a/java-frontend/src/main/java/org/sonar/java/model/DefaultModuleScannerContext.java b/java-frontend/src/main/java/org/sonar/java/model/DefaultModuleScannerContext.java index ad359a3758a..60cda3d1b04 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/DefaultModuleScannerContext.java +++ b/java-frontend/src/main/java/org/sonar/java/model/DefaultModuleScannerContext.java @@ -26,7 +26,6 @@ import org.sonar.api.config.Configuration; import org.sonar.java.SonarComponents; import org.sonar.java.caching.CacheContextImpl; -import org.sonar.java.model.springcontext.SpringContextModel; import org.sonar.java.reporting.AnalyzerMessage; import org.sonar.java.reporting.FluentReporting; import org.sonar.java.reporting.InternalJavaIssueBuilder; @@ -123,8 +122,4 @@ public Configuration getConfiguration() { return sonarComponents.getConfiguration(); } - public SpringContextModel getSpringContextModel() { - return sonarComponents.getSpringContextModel(); - } - } diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java index 651e40b4658..6ae160e9fed 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java @@ -17,12 +17,9 @@ package org.sonar.java.model.springcontext; import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.stream.Collectors; import org.sonar.api.batch.fs.InputFile; import org.sonar.java.caching.FileCachingCheck; import org.sonar.java.model.JUtils; @@ -32,7 +29,6 @@ import org.sonar.java.utils.SpringUtils; import org.sonar.plugins.java.api.InputFileScannerContext; import org.sonar.plugins.java.api.JavaFileScannerContext; -import org.sonar.plugins.java.api.ModuleScannerContext; import org.sonar.plugins.java.api.semantic.SymbolMetadata; import org.sonar.plugins.java.api.tree.ClassTree; import org.sonar.plugins.java.api.tree.MethodTree; @@ -42,8 +38,7 @@ import static org.sonar.java.utils.SpringUtils.collectDependenciesOnMethod; /** - * Collects Spring bean definitions discovered during AST traversal, and registers them in the - * {@link BeanDefinitionRegistry} of the shared {@link SpringContextModel} at the end of the module analysis. + * Collects Spring bean definitions discovered during AST traversal. * *

Discovers beans from: *

    @@ -64,11 +59,6 @@ *
  • Implicit single-constructor injection (no {@code @Autowired} required)
  • *
* - *

Also populates: - *

    - *
  • {@link TypeToBeansIndex} with the full type hierarchy of each bean
  • - *
  • {@link TypeToDependenciesIndex} with all the dependencies collected by type
  • - *
*/ public class BeanDefinitionGatherer extends SpringContextModelGatherer implements FileCachingCheck> { @@ -167,42 +157,6 @@ public void restore(InputFileScannerContext context, List - * Registers all encountered bean definitions in {@link BeanDefinitionRegistry}, - * their position in every ancestor/interface type in {@link TypeToBeansIndex}, and - * each of their dependencies by type in {@link TypeToDependenciesIndex}. - * - * @param context Scanner context used here to access the current module key - * @param springContextModel Shared cross-module Spring context - */ - @Override - public void gatherSpringContextData(ModuleScannerContext context, SpringContextModel springContextModel) { - springContextGatheringModel.getInputFilesData(context.getModuleKey()).forEach(inputFileData -> { - InputFile inputFile = inputFileData.inputFile(); - for (BeanDefinitionHolder.InputFileData data : inputFileData.beans()) { - var location = new BeanLocation(inputFile, data.textSpan()); - var holderBuilder = new BeanDefinitionHolder.Builder( - data.type(), context.getModuleKey(), data.beanPackage(), location) - .dependingBeans(projectToNames(data.dependencies())) - .profileExpression(data.profileExpression()) - .qualifier(data.qualifier()); - if (data.isPrimary()) { - holderBuilder.primary(); - } - springContextModel.getBeanDefinitionRegistry() - .addBeanDefinition(data.beanName(), holderBuilder.build()); - for (String typeFqn : data.typeHierarchy()) { - springContextModel.getTypeToBeansIndex().addBeanForType(typeFqn, data.beanName(), context.getModuleKey(), data.beanPackage()); - } - data.dependencies().forEach((typeFqn, points) -> points.forEach(point -> springContextModel.getTypeToDependenciesIndex() - .addDependencyForType(typeFqn, point.name(), context.getModuleKey(), data.profileExpression(), new BeanLocation(inputFile, point.span()), point.multiple()))); - } - }); - } - @Override protected boolean scanSpringFileWithoutParsing(InputFileScannerContext context) { return restoreFromCache(context); @@ -242,18 +196,4 @@ private void collectBeanMethod(MethodTree method, String pkg, ProfileExpression } } - /** - * Projects each type's injection points down to just their names, discarding spans — the flat view stored in {@code BeanDefinitionHolder}. - * - * @param injectionPointsByType Injection points mapped by type, as collected for a bean - * @return The name of each dependency, mapped by type. - */ - private static Map> projectToNames(Map> injectionPointsByType) { - Map> names = new LinkedHashMap<>(); - injectionPointsByType.forEach((typeFqn, points) -> names.put(typeFqn, points.stream() - .map(InjectionPoint.InputFileData::name) - .collect(Collectors.toCollection(LinkedHashSet::new)))); - return names; - } - } diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/ComponentScanPackageGatherer.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/ComponentScanPackageGatherer.java index 5146693c52e..133114b4c86 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/ComponentScanPackageGatherer.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/ComponentScanPackageGatherer.java @@ -30,7 +30,6 @@ import org.sonar.java.utils.SpringUtils; import org.sonar.plugins.java.api.InputFileScannerContext; import org.sonar.plugins.java.api.JavaFileScannerContext; -import org.sonar.plugins.java.api.ModuleScannerContext; import org.sonar.plugins.java.api.semantic.Symbol; import org.sonar.plugins.java.api.semantic.SymbolMetadata; import org.sonar.plugins.java.api.tree.ClassTree; @@ -38,9 +37,7 @@ import org.sonarsource.analyzer.commons.collections.SetUtils; /** - * Collects packages registered for Spring component scanning and stores them in - * {@link org.sonar.java.model.springcontext.ProjectPackageScan} within the shared - * {@link SpringContextModel}. + * Collects packages registered for Spring component scanning. * *

Scanned packages are derived from: *

    @@ -49,8 +46,7 @@ *
  • {@code @SpringBootApplication} without explicit attributes — the annotated class's own package
  • *
* - *

Packages are grouped by module and written to {@link org.sonar.java.model.springcontext.ProjectPackageScan} - * at the end of each module's analysis. Per-file results are cached to speed up incremental analyses. + *

Per-file results are cached to speed up incremental analyses. */ public class ComponentScanPackageGatherer extends SpringContextModelGatherer implements FileCachingCheck> { @@ -127,11 +123,6 @@ protected void leaveSpringFile(JavaFileScannerContext context) { packagesCollectedAtFileLevel.clear(); } - @Override - public void gatherSpringContextData(ModuleScannerContext context, SpringContextModel springContextModel) { - springContextModel.getProjectPackageScan().addPackages(context.getModuleKey(), springContextGatheringModel.getPackages(context.getModuleKey())); - } - private void collectFromComponentScan(SymbolMetadata metadata) { List componentScanAttributes = metadata.valuesForAnnotation(COMPONENT_SCAN_ANNOTATION); if (componentScanAttributes == null) { 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 907e32a9c2a..6026b1892a5 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 @@ -18,7 +18,6 @@ import com.google.gson.annotations.JsonAdapter; import java.util.ArrayList; -import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -26,61 +25,135 @@ import java.util.Set; import javax.annotation.Nullable; import org.sonar.api.batch.fs.InputFile; +import org.sonar.api.scanner.ScannerSide; import org.sonar.java.serialization.SpringContextGatheringModelTypeAdapter; +import org.sonarsource.api.sonarlint.SonarLintSide; /** * Stores the data collected per-module, per-file during Spring context collection. */ +@ScannerSide +@SonarLintSide @JsonAdapter(SpringContextGatheringModelTypeAdapter.class) public class SpringContextGatheringModel { - /** - * The Spring data collected for a given file in a module. - * - * @param inputFile The source file, absent after JSON deserialization. - * @param beans The bean definitions gathered in the file. - * @param packages The packages covered by the file. - */ - public record InputFileData(@Nullable InputFile inputFile, List beans, Set packages) { + public static final class InputFileData { + @Nullable + private InputFile inputFile; + private final List beans; + private final Set packages; + + private InputFileData(@Nullable InputFile inputFile) { + this.inputFile = inputFile; + this.beans = new ArrayList<>(); + this.packages = new HashSet<>(); + } + + @Nullable + public InputFile inputFile() { + return inputFile; + } + + public List beans() { + return beans; + } + + public Set packages() { + return packages; + } } /** * The Spring data collected per-module, per-file. */ private final Map> filesData = new HashMap<>(); + private boolean restored; + /** + * Returns the file data indexed by module key and file key. + * + * @return An unmodifiable copy of the module map; the nested file maps and their data remain shared. + */ public Map> filesData() { return Map.copyOf(filesData); } + /** + * Returns whether data from a previous model has been restored. + * + * @return {@code true} after the first call to {@link #restoreFrom(SpringContextGatheringModel)}. + */ + public boolean isRestored() { + return restored; + } + + /** + * Restores file data from a previous model once, retaining data already collected for the same files. + * + * @param previous The model containing previously collected file data. + */ + public void restoreFrom(SpringContextGatheringModel previous) { + if (restored) { + return; + } + previous.filesData.forEach((moduleKey, moduleData) -> { + Map current = filesData.computeIfAbsent(moduleKey, key -> new HashMap<>()); + moduleData.forEach(current::putIfAbsent); + }); + restored = true; + } + + /** + * Removes data restored from a previous analysis for files that were not visited during the current analysis. + */ + public void removeUnvisitedFiles() { + filesData.values().forEach(moduleData -> moduleData.values().removeIf(data -> data.inputFile() == null)); + } + + /** + * Registers a module even when it has no collected file data. + * + * @param moduleKey The module key. + */ public void ensureModule(String moduleKey) { filesData.computeIfAbsent(moduleKey, k -> new HashMap<>()); } + /** + * Replaces the bean definitions collected for a file while retaining its collected packages. + * + * @param moduleKey The module key. + * @param fileKey The file key. + * @param inputFile The source file, or {@code null} when unavailable. + * @param beans The bean definitions collected for the file. + */ public void collectBeans(String moduleKey, String fileKey, @Nullable InputFile inputFile, List beans) { - filesData - .computeIfAbsent(moduleKey, k -> new HashMap<>()) - .computeIfAbsent(fileKey, k -> new InputFileData(inputFile, new ArrayList<>(), new HashSet<>())) - .beans.clear(); - filesData.get(moduleKey).get(fileKey).beans.addAll(beans); + InputFileData data = inputFileData(moduleKey, fileKey, inputFile); + data.beans.clear(); + data.beans.addAll(beans); } + /** + * Replaces the packages collected for a file while retaining its collected bean definitions. + * + * @param moduleKey The module key. + * @param fileKey The file key. + * @param inputFile The source file, or {@code null} when unavailable. + * @param packages The packages collected for the file. + */ public void collectPackages(String moduleKey, String fileKey, @Nullable InputFile inputFile, Set packages) { - filesData - .computeIfAbsent(moduleKey, k -> new HashMap<>()) - .computeIfAbsent(fileKey, k -> new InputFileData(inputFile, new ArrayList<>(), new HashSet<>())) - .packages.clear(); - filesData.get(moduleKey).get(fileKey).packages.addAll(packages); - } - - public Collection getInputFilesData(String moduleKey) { - return filesData.getOrDefault(moduleKey, Map.of()).values(); + InputFileData data = inputFileData(moduleKey, fileKey, inputFile); + data.packages.clear(); + data.packages.addAll(packages); } - public Set getPackages(String moduleKey) { - Set packages = new HashSet<>(); - getInputFilesData(moduleKey).forEach(inputFileData -> packages.addAll(inputFileData.packages)); - return packages; + private InputFileData inputFileData(String moduleKey, String fileKey, @Nullable InputFile inputFile) { + Map moduleData = filesData.computeIfAbsent(moduleKey, k -> new HashMap<>()); + InputFileData data = moduleData.computeIfAbsent(fileKey, k -> new InputFileData(inputFile)); + if (inputFile != null) { + data.inputFile = inputFile; + } + return data; } } diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextModel.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextModel.java index ee7874d2434..a32b36a257c 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextModel.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/SpringContextModel.java @@ -16,16 +16,21 @@ */ package org.sonar.java.model.springcontext; -import org.sonar.api.scanner.ScannerSide; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.sonar.api.batch.fs.InputFile; import org.sonar.java.telemetry.SizeEstimable; import org.sonar.java.telemetry.SizeEstimator; -import org.sonarsource.api.sonarlint.SonarLintSide; /** * Aggregates all Spring context information collected during project scanning. * *

Acts as the top-level model passed to rules that need to reason about the Spring - * application context. Each field is a specialized index populated during the scan phase: + * application context. Each field is a specialized index loaded after Spring context collection: *

    *
  • {@link BeanDefinitionRegistry} — bean definitions indexed by bean name
  • *
  • {@link ProjectPackageScan} — packages registered for component scanning, per module
  • @@ -33,8 +38,6 @@ *
  • {@link EntityClassToPropertiesIndex} — JPA {@code @Entity} class properties
  • *
*/ -@ScannerSide -@SonarLintSide public class SpringContextModel implements SizeEstimable { /** * Registry of all bean definitions discovered during scanning. @@ -61,6 +64,22 @@ public class SpringContextModel implements SizeEstimable { */ private final EntityClassToPropertiesIndex entityClassToPropertiesIndex = new EntityClassToPropertiesIndex(); + /** + * Builds a new model from every module and file contribution collected during Spring context gathering. + */ + public static SpringContextModel of(SpringContextGatheringModel gatheringModel) { + var model = new SpringContextModel(); + gatheringModel.filesData().forEach((moduleKey, files) -> { + Set packages = new HashSet<>(); + files.values().forEach(fileData -> { + packages.addAll(fileData.packages()); + model.addBeans(moduleKey, fileData); + }); + model.projectPackageScan.addPackages(moduleKey, packages); + }); + return model; + } + public BeanDefinitionRegistry getBeanDefinitionRegistry() { return beanDefinitionRegistry; } @@ -90,4 +109,32 @@ public long estimateSize(SizeEstimator estimator) { + estimator.estimateObject(typeToDependenciesIndex) + estimator.estimateObject(entityClassToPropertiesIndex); } + + private void addBeans(String moduleKey, SpringContextGatheringModel.InputFileData fileData) { + InputFile inputFile = fileData.inputFile(); + for (BeanDefinitionHolder.InputFileData data : fileData.beans()) { + var location = new BeanLocation(inputFile, data.textSpan()); + var holderBuilder = new BeanDefinitionHolder.Builder(data.type(), moduleKey, data.beanPackage(), location) + .dependingBeans(projectToNames(data.dependencies())) + .profileExpression(data.profileExpression()) + .qualifier(data.qualifier()); + if (data.isPrimary()) { + holderBuilder.primary(); + } + beanDefinitionRegistry.addBeanDefinition(data.beanName(), holderBuilder.build()); + for (String typeFqn : data.typeHierarchy()) { + typeToBeansIndex.addBeanForType(typeFqn, data.beanName(), moduleKey, data.beanPackage()); + } + data.dependencies().forEach((typeFqn, points) -> points.forEach(point -> typeToDependenciesIndex + .addDependencyForType(typeFqn, point.name(), moduleKey, data.profileExpression(), new BeanLocation(inputFile, point.span()), point.multiple()))); + } + } + + private static Map> projectToNames(Map> injectionPointsByType) { + Map> names = new LinkedHashMap<>(); + injectionPointsByType.forEach((typeFqn, points) -> names.put(typeFqn, points.stream() + .map(InjectionPoint.InputFileData::name) + .collect(Collectors.toCollection(LinkedHashSet::new)))); + return names; + } } 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 3af82f14701..48c9b8011fc 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 @@ -21,7 +21,6 @@ import java.util.function.Function; import java.util.function.LongSupplier; import org.sonar.java.annotations.VisibleForTesting; -import org.sonar.java.model.DefaultModuleScannerContext; import org.sonar.java.telemetry.Telemetry; import org.sonar.java.telemetry.TelemetryKey; import org.sonar.plugins.java.api.DependencyVersionAware; @@ -34,10 +33,9 @@ import org.sonar.plugins.java.api.tree.Tree; /** - * Base class for visitors that need to gather data in the SpringContextModel at the end of the analysis. + * Base class for visitors that collect data for the project-wide Spring context model. * Extending classes gather relevant spring-related data by implementing {@link #visitSpringNode}, - * {@link #leaveSpringFile} and {@link #scanSpringFileWithoutParsing}, and store it in the SpringContextModel at the - * end of a module analysis. + * {@link #leaveSpringFile} and {@link #scanSpringFileWithoutParsing}. * *

The corresponding scanner entry points are final: this class implements them to measure how long gathering takes * and report it under {@link TelemetryKey#JAVA_SPRING_CONTEXT_MODEL_GATHERING_TIME_MS}. Durations are accumulated in @@ -139,26 +137,12 @@ public final boolean scanWithoutParsing(InputFileScannerContext context) { @Override public final void endOfAnalysis(ModuleScannerContext context) { - var defaultModuleContext = (DefaultModuleScannerContext) context; - long startTime = nanoTime.getAsLong(); - try { - gatherSpringContextData(context, defaultModuleContext.getSpringContextModel()); - } finally { - recordElapsedTime(startTime); - telemetry.aggregateAsCounter( - TelemetryKey.JAVA_SPRING_CONTEXT_MODEL_GATHERING_TIME_MS, - TimeUnit.NANOSECONDS.toMillis(gatheringTimeNanos)); - gatheringTimeNanos = 0; - } + telemetry.aggregateAsCounter(TelemetryKey.JAVA_SPRING_CONTEXT_MODEL_GATHERING_TIME_MS, TimeUnit.NANOSECONDS.toMillis(gatheringTimeNanos)); + gatheringTimeNanos = 0; } private void recordElapsedTime(long startTime) { gatheringTimeNanos += nanoTime.getAsLong() - startTime; } - /** - * Method called at the end of the analysis of a module, allowing to store gathered data in the SpringContextModel. - */ - public abstract void gatherSpringContextData(ModuleScannerContext context, SpringContextModel springContextModel); - } 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 173a69f3e51..fbb404a1ad5 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 @@ -18,10 +18,9 @@ import java.util.List; import org.sonar.java.telemetry.Telemetry; -import org.sonar.plugins.java.api.JavaCheck; /** - * Registry of all {@link SpringContextModelGatherer} visitors that populate the {@link SpringContextModel} + * Registry of all {@link SpringContextModelGatherer} visitors that collect data for the {@link SpringContextModel} * during a module analysis. * *

Use {@link #getAllGatherers(SpringContextGatheringModel, Telemetry)} to obtain the full list of gatherers to be registered with the scanner. @@ -36,9 +35,9 @@ private SpringContextModelGatherers() { /** * Returns all gatherers that contribute data to the {@link SpringContextModel}. * - * @return a list of {@link JavaCheck} instances, each implementing {@link SpringContextModelGatherer} + * @return The gatherers to register with a scanner. */ - public static List getAllGatherers(SpringContextGatheringModel springContextGatheringModel, Telemetry telemetry) { + public static List getAllGatherers(SpringContextGatheringModel springContextGatheringModel, Telemetry telemetry) { return List.of( new ComponentScanPackageGatherer(springContextGatheringModel, telemetry), new BeanDefinitionGatherer(springContextGatheringModel, telemetry) diff --git a/java-frontend/src/test/java/org/sonar/java/JavaFrontendTest.java b/java-frontend/src/test/java/org/sonar/java/JavaFrontendTest.java index b06e36c7f8e..fdfd31bf296 100644 --- a/java-frontend/src/test/java/org/sonar/java/JavaFrontendTest.java +++ b/java-frontend/src/test/java/org/sonar/java/JavaFrontendTest.java @@ -55,7 +55,9 @@ import org.sonar.java.exceptions.ApiMismatchException; import org.sonar.java.filters.SonarJavaIssueFilter; import org.sonar.java.model.JavaVersionImpl; +import org.sonar.java.model.springcontext.SpringContextGatheringModel; import org.sonar.java.model.springcontext.SpringContextModel; +import org.sonar.java.model.springcontext.SpringContextModelGatherers; import org.sonar.java.telemetry.NoOpTelemetry; import org.sonar.java.test.classpath.TestClasspathUtils; import org.sonar.plugins.java.api.CheckRegistrar; @@ -255,12 +257,17 @@ void spring_bean_is_registered_once_when_main_and_test_scanners_finish() throws sonarComponents = new SonarComponents(fileLinesContextFactory, sensorContext.fileSystem(), javaClasspath, javaTestClasspath, mock(CheckFactory.class), mock(ActiveRules.class)); sonarComponents.setSensorContext(sensorContext); - var springContextModel = new SpringContextModel(); - sonarComponents.setSpringContextModel(springContextModel); - - JavaFrontend frontend = new JavaFrontend(new JavaVersionImpl(), sonarComponents, new Measurer(sensorContext, mock(NoSonarFilter.class)), new NoOpTelemetry(), - mock(JavaResourceLocator.class), null); + var gatheringModel = new SpringContextGatheringModel(); + var telemetry = new NoOpTelemetry(); + SpringContextModelGatherers.getAllGatherers(gatheringModel, telemetry) + .forEach(gatherer -> sonarComponents.registerCustomFileScanner(RuleScope.MAIN, gatherer)); + SpringContextModelGatherers.getAllGatherers(gatheringModel, telemetry) + .forEach(gatherer -> sonarComponents.registerCustomFileScanner(RuleScope.TEST, gatherer)); + + JavaFrontend frontend = new JavaFrontend(new JavaVersionImpl(), sonarComponents, new Measurer(sensorContext, mock(NoSonarFilter.class)), telemetry, + mock(JavaResourceLocator.class), null, sonarComponents.mainChecks().toArray(new JavaCheck[0])); frontend.scan(List.of(mainFile), List.of(testFile), List.of()); + var springContextModel = SpringContextModel.of(gatheringModel); assertThat(springContextModel.getBeanDefinitionRegistry().getByName("mainBean")).hasSize(1); assertThat(springContextModel.getBeanDefinitionRegistry().getByName("helperTest")).hasSize(1); diff --git a/java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java b/java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java index ad0df1d3643..071e912b4d4 100644 --- a/java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java +++ b/java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java @@ -35,7 +35,6 @@ import org.sonar.java.TestUtils; import org.sonar.java.telemetry.NoOpTelemetry; import org.sonar.plugins.java.api.InputFileScannerContext; -import org.sonar.plugins.java.api.ModuleScannerContext; import org.sonar.plugins.java.api.caching.CacheContext; import org.sonar.plugins.java.api.caching.JavaReadCache; import org.sonar.plugins.java.api.caching.JavaWriteCache; @@ -322,9 +321,7 @@ void scanWithoutParsing_restores_beans_from_cache() { assertThat(gatherer.scanWithoutParsing(context)).isTrue(); verify(writeCache).copyFromPrevious(entry.key()); - ModuleScannerContext moduleScannerContext = mock(ModuleScannerContext.class); - when(moduleScannerContext.getModuleKey()).thenReturn(""); - gatherer.gatherSpringContextData(moduleScannerContext, model); + model = SpringContextModel.of(gatherer.springContextGatheringModel); var beans = model.getBeanDefinitionRegistry().getByName("qualifiedFieldDependencies"); assertThat(beans).hasSize(1); diff --git a/java-frontend/src/test/java/org/sonar/java/model/springcontext/ComponentScanPackageGathererTest.java b/java-frontend/src/test/java/org/sonar/java/model/springcontext/ComponentScanPackageGathererTest.java index e10ac5712b9..7ca883b766f 100644 --- a/java-frontend/src/test/java/org/sonar/java/model/springcontext/ComponentScanPackageGathererTest.java +++ b/java-frontend/src/test/java/org/sonar/java/model/springcontext/ComponentScanPackageGathererTest.java @@ -28,7 +28,6 @@ import org.sonar.java.TestUtils; import org.sonar.java.telemetry.NoOpTelemetry; import org.sonar.plugins.java.api.InputFileScannerContext; -import org.sonar.plugins.java.api.ModuleScannerContext; import org.sonar.plugins.java.api.caching.CacheContext; import org.sonar.plugins.java.api.caching.JavaReadCache; import org.sonar.plugins.java.api.caching.JavaWriteCache; @@ -137,10 +136,7 @@ void no_scan_annotations_collects_nothing() { @Test void module_without_scanned_files_collects_nothing() { - ModuleScannerContext context = mock(ModuleScannerContext.class); - when(context.getModuleKey()).thenReturn(MODULE_KEY); - - gatherer.gatherSpringContextData(context, model); + model = SpringContextModel.of(gatherer.springContextGatheringModel); assertThat(model.getProjectPackageScan().getPackagesForModule(MODULE_KEY)).isEmpty(); } @@ -191,9 +187,7 @@ void scanWithoutParsing_restores_packages_from_cache() { assertThat(gatherer.scanWithoutParsing(context)).isTrue(); - ModuleScannerContext moduleScannerContext = mock(ModuleScannerContext.class); - when(moduleScannerContext.getModuleKey()).thenReturn(MODULE_KEY); - gatherer.gatherSpringContextData(moduleScannerContext, model); + model = SpringContextModel.of(gatherer.springContextGatheringModel); assertThat(model.getProjectPackageScan().getPackagesForModule(MODULE_KEY)) .containsExactlyInAnyOrder("com.example.service", "com.example.web"); diff --git a/java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextGathererTest.java b/java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextGathererTest.java index 777456f6572..3cc66e11a14 100644 --- a/java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextGathererTest.java +++ b/java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextGathererTest.java @@ -55,9 +55,6 @@ protected void scan(SensorContextTester ctx, String... filePaths) { protected void scan(List classpath, SensorContextTester ctx, String... filePaths) { var sonarComponents = new SonarComponents(null, null, null, null, null, null); sonarComponents.setSensorContext(ctx); - sonarComponents.setSpringContextModel(model); - - VisitorsBridge visitorsBridge = new VisitorsBridge(List.of((JavaCheck) gatherer), classpath, sonarComponents); for (String filePath : filePaths) { File file = new File(filePath); @@ -66,6 +63,7 @@ protected void scan(List classpath, SensorContextTester ctx, String... fil visitorsBridge.visitFile(compilationUnit, false); } visitorsBridge.endOfAnalysis(); + model = SpringContextModel.of(gatherer.springContextGatheringModel); } protected static CacheContext mockCacheContext(JavaReadCache readCache, JavaWriteCache writeCache) { diff --git a/java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextGatheringModelTest.java b/java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextGatheringModelTest.java new file mode 100644 index 00000000000..19a4c992a4f --- /dev/null +++ b/java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextGatheringModelTest.java @@ -0,0 +1,142 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.java.model.springcontext; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.java.reporting.AnalyzerMessage.TextSpan; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +class SpringContextGatheringModelTest { + + @Test + void restore_preserves_current_files_fills_missing_files_and_runs_once() { + var previous = new SpringContextGatheringModel(); + previous.collectPackages("module-a", "current", null, Set.of("old")); + previous.collectPackages("module-a", "restored", null, Set.of("restored")); + previous.collectPackages("module-b", "other", null, Set.of("other")); + previous.ensureModule("empty"); + + var model = new SpringContextGatheringModel(); + model.collectPackages("module-a", "current", mock(InputFile.class), Set.of("current")); + assertThat(model.isRestored()).isFalse(); + + model.restoreFrom(previous); + + assertThat(model.isRestored()).isTrue(); + assertThat(model.filesData()).containsOnlyKeys("module-a", "module-b", "empty"); + assertThat(model.filesData().get("module-a")).containsOnlyKeys("current", "restored"); + assertThat(model.filesData().get("module-a").get("current").packages()).containsExactly("current"); + assertThat(model.filesData().get("module-a").get("restored").packages()).containsExactly("restored"); + assertThat(model.filesData().get("module-b").get("other").packages()).containsExactly("other"); + assertThat(model.filesData().get("empty")).isEmpty(); + + var later = new SpringContextGatheringModel(); + later.collectPackages("module-c", "new", null, Set.of("new")); + model.restoreFrom(later); + + assertThat(model.filesData()).doesNotContainKey("module-c"); + } + + @Test + void collection_replaces_one_kind_of_data_and_keeps_the_latest_known_input_file() { + var model = new SpringContextGatheringModel(); + var firstBean = bean("first"); + var secondBean = bean("second"); + var thirdBean = bean("third"); + InputFile firstFile = mock(InputFile.class); + InputFile secondFile = mock(InputFile.class); + + model.collectBeans("module", "file", null, List.of(firstBean)); + model.collectPackages("module", "file", null, Set.of("old.package")); + model.collectBeans("module", "file", firstFile, List.of(secondBean)); + + var data = model.filesData().get("module").get("file"); + assertThat(data.inputFile()).isSameAs(firstFile); + assertThat(data.beans()).containsExactly(secondBean); + assertThat(data.packages()).containsExactly("old.package"); + + model.collectPackages("module", "file", firstFile, Set.of("new.package")); + model.collectBeans("module", "file", secondFile, List.of(thirdBean)); + model.collectPackages("module", "file", null, Set.of("latest.package")); + + assertThat(model.filesData().get("module").get("file")).isSameAs(data); + assertThat(data.inputFile()).isSameAs(secondFile); + assertThat(data.beans()).containsExactly(thirdBean); + assertThat(data.packages()).containsExactly("latest.package"); + } + + @Test + void remove_unvisited_files_keeps_visited_files_and_empty_modules() { + var previous = new SpringContextGatheringModel(); + previous.collectPackages("module", "stale", null, Set.of("stale.package")); + previous.collectPackages("other", "stale", null, Set.of("other.package")); + previous.ensureModule("empty"); + var model = new SpringContextGatheringModel(); + InputFile visitedFile = mock(InputFile.class); + model.collectPackages("module", "visited", visitedFile, Set.of("current.package")); + model.restoreFrom(previous); + + model.removeUnvisitedFiles(); + + assertThat(model.filesData()).containsOnlyKeys("module", "other", "empty"); + assertThat(model.filesData().get("module")).containsOnlyKeys("visited"); + assertThat(model.filesData().get("module").get("visited").inputFile()).isSameAs(visitedFile); + assertThat(model.filesData().get("other")).isEmpty(); + assertThat(model.filesData().get("empty")).isEmpty(); + } + + @Test + void builds_deduplicated_packages_by_module() { + var model = new SpringContextGatheringModel(); + model.collectPackages("module", "first", null, Set.of("shared", "first")); + model.collectPackages("module", "second", null, Set.of("shared", "second")); + model.collectPackages("other", "third", null, Set.of("third")); + model.ensureModule("empty"); + model.ensureModule("empty"); + + var projectPackageScan = SpringContextModel.of(model).getProjectPackageScan(); + assertThat(projectPackageScan.getPackagesForModule("module")).containsExactlyInAnyOrder("shared", "first", "second"); + assertThat(projectPackageScan.getPackagesForModule("other")).containsExactly("third"); + assertThat(projectPackageScan.getPackagesForModule("empty")).isEmpty(); + } + + @Test + void files_data_returns_an_unmodifiable_outer_snapshot_with_shared_file_maps() { + var model = new SpringContextGatheringModel(); + model.ensureModule("module"); + var snapshot = model.filesData(); + + assertThatThrownBy(() -> snapshot.put("new", Map.of())).isInstanceOf(UnsupportedOperationException.class); + model.ensureModule("later"); + model.collectPackages("module", "file", null, Set.of("package")); + + assertThat(snapshot).doesNotContainKey("later"); + assertThat(snapshot.get("module")).containsOnlyKeys("file"); + } + + private static BeanDefinitionHolder.InputFileData bean(String name) { + return new BeanDefinitionHolder.InputFileData(name, "example.Bean", "example", new TextSpan(1, 0, 1, 4), + false, ProfileExpression.UNCONDITIONAL, null, Map.of(), Set.of("example.Bean")); + } +} diff --git a/java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextModelGathererTest.java b/java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextModelGathererTest.java index 3938ddb9fa2..31b8eee5f44 100644 --- a/java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextModelGathererTest.java +++ b/java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextModelGathererTest.java @@ -37,7 +37,6 @@ import org.sonar.java.test.classpath.TestClasspathUtils; import org.sonar.plugins.java.api.InputFileScannerContext; import org.sonar.plugins.java.api.JavaCheck; -import org.sonar.plugins.java.api.ModuleScannerContext; import org.sonar.plugins.java.api.Version; import org.sonar.plugins.java.api.tree.Tree; @@ -50,21 +49,18 @@ class SpringContextModelGathererTest { private static final String SIMPLE_CLASS = "src/test/files/model/SimpleClass.java"; private static final List CLASSPATH = TestClasspathUtils.DEFAULT_MODULE.getClassPath(); private static final long ONE_MILLISECOND_IN_NANOS = 1_000_000L; - private static final long SUB_MILLISECOND_IN_NANOS = 400_000L; + private static final long SUB_MILLISECOND_IN_NANOS = 600_000L; /** - * {@code SimpleClass.java} holds a single class, so scanning it goes through three measured phases: - * one {@code visitNode}, one {@code leaveFile} and one {@code endOfAnalysis}. + * {@code SimpleClass.java} holds a single class, so scanning it goes through two measured phases: + * one {@code visitNode} and one {@code leaveFile}. */ - private static final int PHASES_OF_A_PARSED_FILE = 3; - - private final SpringContextModel model = new SpringContextModel(); + private static final int PHASES_OF_A_PARSED_FILE = 2; @Test void gathering_time_is_reported() { var telemetry = new DefaultTelemetry(); scanFile(SIMPLE_CLASS, new SampleGatherer(new SpringContextGatheringModel(), telemetry), CLASSPATH); - assertThat(model.getTypeToBeansIndex().getNamesForType("com.example.MyService", "", Set.of())).containsExactly("myServiceBean"); assertThat(telemetry.toMap().get(GATHERING_TIME_KEY)).matches("\\d+"); } @@ -81,11 +77,11 @@ void restoring_a_file_from_the_cache_is_measured() { var gatherer = new SampleGatherer(new SpringContextGatheringModel(), telemetry, new IncrementingNanoTime(ONE_MILLISECOND_IN_NANOS)); gatherer.scanWithoutParsing(mock(InputFileScannerContext.class)); bridgeFor(gatherer, CLASSPATH).endOfAnalysis(); - assertThat(telemetry.toMap()).containsEntry(GATHERING_TIME_KEY, "2"); + assertThat(telemetry.toMap()).containsEntry(GATHERING_TIME_KEY, "1"); } /** - * Each of the three phases lasts 0.4 ms and would be truncated to 0 ms if converted on its own, + * Each of the two phases lasts 0.6 ms and would be truncated to 0 ms if converted on its own, * but together they reach 1.2 ms. */ @Test @@ -125,7 +121,6 @@ private VisitorsBridge bridgeFor(JavaCheck check, List classpath) { SensorContextTester sensorContextTester = SensorContextTester.create(new File("")); var sonarComponents = new SonarComponents(null, null, null, null, null, null); sonarComponents.setSensorContext(sensorContextTester); - sonarComponents.setSpringContextModel(model); return new VisitorsBridge(List.of(check), classpath, sonarComponents); } @@ -144,11 +139,6 @@ static class SampleGatherer extends SpringContextModelGatherer { super(springContextGatheringModel, telemetry, nanoTime); } - @Override - public void gatherSpringContextData(ModuleScannerContext context, SpringContextModel springContextModel) { - springContextModel.getTypeToBeansIndex().addBeanForType("com.example.MyService", "myServiceBean", context.getModuleKey(), "com.example"); - } - @Override public List nodesToVisit() { return List.of(Tree.Kind.CLASS); diff --git a/java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextModelTest.java b/java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextModelTest.java index dba9b47bada..76ba96eeace 100644 --- a/java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextModelTest.java +++ b/java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextModelTest.java @@ -16,8 +16,10 @@ */ package org.sonar.java.model.springcontext; +import java.io.File; import java.util.List; import org.junit.jupiter.api.Test; +import org.sonar.api.batch.fs.InputFile; import org.sonar.java.JavaFrontend; import org.sonar.java.Measurer; import org.sonar.java.SonarComponents; @@ -25,6 +27,7 @@ import org.sonar.java.model.JavaVersionImpl; import org.sonar.java.telemetry.NoOpTelemetry; import org.sonar.java.test.classpath.TestClasspathUtils; +import org.sonar.plugins.java.api.JavaCheck; import org.sonar.plugins.java.api.JavaResourceLocator; import static org.assertj.core.api.Assertions.assertThat; @@ -46,21 +49,24 @@ void testInitialization() { @Test void scan_fills_project_package_scan_in_spring_context_model() { - SpringContextModel springContextModel = new SpringContextModel(); SonarComponents sonarComponents = TestUtils.mockSonarComponents(); - when(sonarComponents.getSpringContextModel()).thenReturn(springContextModel); when(sonarComponents.getJavaClasspath()).thenReturn(TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + when(sonarComponents.getJavaTestClasspath()).thenReturn(TestClasspathUtils.DEFAULT_MODULE.getClassPath()); when(sonarComponents.getModuleKey()).thenReturn("a"); - JavaFrontend frontend = new JavaFrontend(new JavaVersionImpl(), sonarComponents, mock(Measurer.class), new NoOpTelemetry(), mock(JavaResourceLocator.class), null); + var gatheringModel = new SpringContextGatheringModel(); + var telemetry = new NoOpTelemetry(); + List testGatherers = List.copyOf(SpringContextModelGatherers.getAllGatherers(gatheringModel, telemetry)); + when(sonarComponents.testChecks()).thenReturn(testGatherers); + var mainGatherers = SpringContextModelGatherers.getAllGatherers(gatheringModel, telemetry).toArray(new JavaCheck[0]); + JavaFrontend frontend = new JavaFrontend(new JavaVersionImpl(), sonarComponents, mock(Measurer.class), telemetry, mock(JavaResourceLocator.class), null, + mainGatherers); frontend.scan( - List.of( - TestUtils.inputFile("src/test/files/springcontext/SpringBootApp.java"), - TestUtils.inputFile("src/test/files/springcontext/SpringContextComponent.java") - ), - List.of(), + List.of(TestUtils.inputFile("src/test/files/springcontext/SpringBootApp.java")), + List.of(TestUtils.inputFile("", new File("src/test/files/springcontext/SpringContextComponent.java"), InputFile.Type.TEST)), List.of() ); + SpringContextModel springContextModel = SpringContextModel.of(gatheringModel); assertThat(springContextModel.getProjectPackageScan().getModules()).isNotEmpty(); assertThat(springContextModel.getProjectPackageScan().getPackagesForModule("a")) 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 e783aeca8dd..4eba1ed9dea 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 @@ -36,9 +36,9 @@ import org.sonar.java.classpath.ClasspathProperties; import org.sonar.java.filters.PostAnalysisIssueFilter; import org.sonar.java.jsp.Jasper; -import org.sonar.java.model.springcontext.SpringContextModel; -import org.sonar.java.telemetry.NoOpTelemetry; +import org.sonar.java.model.springcontext.SpringContextGatheringModel; import org.sonar.java.telemetry.DefaultTelemetry; +import org.sonar.java.telemetry.NoOpTelemetry; import org.sonar.plugins.java.api.JavaVersion; import org.sonar.plugins.java.api.caching.SonarLintCache; import org.sonar.plugins.surefire.SurefireExtensions; @@ -83,9 +83,17 @@ public void define(Context context) { .onConfigScopes(Set.of(PropertyDefinition.ConfigScope.PROJECT)) .build(), JavaRulesDefinition.class, - SpringContextModel.class, + SpringContextGatheringModel.class, SonarComponents.class, DefaultJavaResourceLocator.class, + PropertyDefinition.builder(JavaSensor.SPRING_CONTEXT_MODEL_PATH_PROPERTY) + .name("Spring context model file") + .description("Path to the JSON file used to restore Spring context data at the start of analysis and save it afterward. " + + "When unset, no file is read or written. Relative paths use the root project directory.") + .category(JavaConstants.JAVA_CATEGORY) + .subCategory("General") + .onConfigScopes(Set.of(PropertyDefinition.ConfigScope.PROJECT)) + .build(), PropertyDefinition.builder(JavaVersion.ENABLE_PREVIEW) .name("Enable JDK's latest preview feature") .description("Allow to enable JDK's preview features for analysis. Only the Java's latest supported version preview features are supported.") 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 44ccaef460c..e5c06a2f44e 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 @@ -34,6 +34,7 @@ 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; @@ -42,7 +43,8 @@ import org.sonar.java.jsp.Jasper; import org.sonar.java.model.GeneratedFile; import org.sonar.java.model.JavaVersionImpl; -import org.sonar.java.model.springcontext.SpringContextModel; +import org.sonar.java.model.springcontext.SpringContextGatheringModel; +import org.sonar.java.model.springcontext.SpringContextModelGatherers; import org.sonar.java.telemetry.Telemetry; import org.sonar.plugins.java.api.JavaCheck; import org.sonar.plugins.java.api.JavaResourceLocator; @@ -62,6 +64,7 @@ public class JavaSensor implements Sensor { private static final String PERFORMANCE_MEASURE_ACTIVATION_PROPERTY = "sonar.java.performance.measure"; private static final String PERFORMANCE_MEASURE_FILE_PATH_PROPERTY = "sonar.java.performance.measure.path"; private static final String PERFORMANCE_MEASURE_DESTINATION_FILE = "sonar.java.performance.measure.json"; + public static final String SPRING_CONTEXT_MODEL_PATH_PROPERTY = "sonar.java.springContext.model.path"; private final SonarComponents sonarComponents; private final JavaResourceLocator javaResourceLocator; @@ -70,25 +73,28 @@ public class JavaSensor implements Sensor { private final Jasper jasper; private final PostAnalysisIssueFilter postAnalysisIssueFilter; private final Telemetry telemetry; + private final SpringContextGatheringModel springContextGatheringModel; - public JavaSensor(SonarComponents sonarComponents, JavaResourceLocator javaResourceLocator, - NoSonarFilter noSonarFilter, PostAnalysisIssueFilter postAnalysisIssueFilter, - Telemetry telemetry, SpringContextModel springContextModel) { - this(sonarComponents, javaResourceLocator, noSonarFilter, postAnalysisIssueFilter, null, telemetry, springContextModel); + public JavaSensor(SonarComponents sonarComponents, JavaResourceLocator javaResourceLocator, NoSonarFilter noSonarFilter, + PostAnalysisIssueFilter postAnalysisIssueFilter, Telemetry telemetry, SpringContextGatheringModel springContextGatheringModel) { + this(sonarComponents, javaResourceLocator, noSonarFilter, postAnalysisIssueFilter, null, telemetry, springContextGatheringModel); } - public JavaSensor(SonarComponents sonarComponents, JavaResourceLocator javaResourceLocator, - NoSonarFilter noSonarFilter, PostAnalysisIssueFilter postAnalysisIssueFilter, - @Nullable Jasper jasper, Telemetry telemetry, SpringContextModel springContextModel) { + public JavaSensor(SonarComponents sonarComponents, JavaResourceLocator javaResourceLocator, NoSonarFilter noSonarFilter, + PostAnalysisIssueFilter postAnalysisIssueFilter, @Nullable Jasper jasper, Telemetry telemetry, SpringContextGatheringModel springContextGatheringModel) { this.noSonarFilter = noSonarFilter; this.sonarComponents = sonarComponents; this.javaResourceLocator = javaResourceLocator; this.postAnalysisIssueFilter = postAnalysisIssueFilter; this.jasper = jasper; this.telemetry = telemetry; + this.springContextGatheringModel = springContextGatheringModel; this.sonarComponents.registerMainChecks(GeneratedCheckList.REPOSITORY_KEY, GeneratedCheckList.getJavaChecks()); this.sonarComponents.registerTestChecks(GeneratedCheckList.REPOSITORY_KEY, GeneratedCheckList.getJavaTestChecks()); - this.sonarComponents.setSpringContextModel(springContextModel); + SpringContextModelGatherers.getAllGatherers(springContextGatheringModel, telemetry) + .forEach(gatherer -> this.sonarComponents.registerCustomFileScanner(RuleScope.MAIN, gatherer)); + SpringContextModelGatherers.getAllGatherers(springContextGatheringModel, telemetry) + .forEach(gatherer -> this.sonarComponents.registerCustomFileScanner(RuleScope.TEST, gatherer)); } @Override @@ -101,6 +107,10 @@ public void execute(SensorContext context) { PerformanceMeasure.Duration sensorDuration = createPerformanceMeasureReport(context); sonarComponents.setSensorContext(context); + if (!springContextGatheringModel.isRestored()) { + SpringContextModelPersistence.configuredPath(context, sonarComponents.projectLevelBaseDir()) + .ifPresent(path -> springContextGatheringModel.restoreFrom(SpringContextModelPersistence.load(path))); + } sonarComponents.setCheckFilter(createCheckFilter(sonarComponents.isAutoScanCheckFiltering())); Measurer measurer = new Measurer(context, noSonarFilter); diff --git a/sonar-java-plugin/src/main/java/org/sonar/plugins/java/ProjectEndOfAnalysisSensor.java b/sonar-java-plugin/src/main/java/org/sonar/plugins/java/ProjectEndOfAnalysisSensor.java index 6176731a6ad..8179eb04bd3 100644 --- a/sonar-java-plugin/src/main/java/org/sonar/plugins/java/ProjectEndOfAnalysisSensor.java +++ b/sonar-java-plugin/src/main/java/org/sonar/plugins/java/ProjectEndOfAnalysisSensor.java @@ -24,10 +24,7 @@ import org.sonar.api.batch.sensor.SensorDescriptor; import org.sonar.api.scanner.sensor.ProjectSensor; import org.sonar.java.jsp.Jasper; -import org.sonar.java.model.springcontext.SpringContextModel; -import org.sonar.java.model.springcontext.SpringContextModelMetrics; import org.sonar.java.telemetry.Telemetry; -import org.sonar.java.telemetry.TelemetryKey; /** * Sensor that runs at the end of the project's analysis to send telemetry data. @@ -40,11 +37,9 @@ public class ProjectEndOfAnalysisSensor implements ProjectSensor { private static final Logger LOG = LoggerFactory.getLogger(ProjectEndOfAnalysisSensor.class); private final Telemetry telemetry; - private final SpringContextModel springContextModel; - public ProjectEndOfAnalysisSensor(Telemetry telemetry, SpringContextModel springContextModel) { + public ProjectEndOfAnalysisSensor(Telemetry telemetry) { this.telemetry = telemetry; - this.springContextModel = springContextModel; } @Override @@ -54,26 +49,10 @@ public void describe(SensorDescriptor descriptor) { @Override public void execute(SensorContext context) { - recordSpringTelemetry(); telemetry.toMap().forEach((key, value) -> { LOG.debug("Telemetry {}: {}", key, value); context.addTelemetryProperty(key, value); }); } - /** - * Records the Spring counters unconditionally, so that projects without Spring report zeros rather than nothing and - * remain distinguishable from projects analyzed by a version of the analyzer that did not report them at all. - */ - private void recordSpringTelemetry() { - var metrics = SpringContextModelMetrics.of(springContextModel); - telemetry.aggregateAsCounter(TelemetryKey.JAVA_SPRING_BEAN_COUNT, metrics.beanCount()); - telemetry.aggregateAsCounter(TelemetryKey.JAVA_SPRING_BEAN_NAME_COUNT, metrics.beanNameCount()); - telemetry.aggregateAsCounter(TelemetryKey.JAVA_SPRING_INJECTION_POINT_COUNT, metrics.injectionPointCount()); - telemetry.aggregateAsCounter(TelemetryKey.JAVA_SPRING_COMPONENT_SCAN_PACKAGE_COUNT, metrics.componentScanPackageCount()); - telemetry.aggregateAsCounter(TelemetryKey.JAVA_SPRING_CONTEXT_MODEL_GATHERING_TIME_MS, 0L); - telemetry.aggregateAsCounter(TelemetryKey.JAVA_SPRING_CONTEXT_CHECKS_TIME_MS, 0L); - telemetry.aggregateAsCounter(TelemetryKey.JAVA_SPRING_CONTEXT_MODEL_SIZE_BYTES, metrics.estimatedSizeInBytes()); - } - } diff --git a/sonar-java-plugin/src/main/java/org/sonar/plugins/java/SpringContextModelPersistence.java b/sonar-java-plugin/src/main/java/org/sonar/plugins/java/SpringContextModelPersistence.java new file mode 100644 index 00000000000..f34bb2020c3 --- /dev/null +++ b/sonar-java-plugin/src/main/java/org/sonar/plugins/java/SpringContextModelPersistence.java @@ -0,0 +1,80 @@ +/* + * SonarQube Java + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * You can redistribute and/or modify this program under the terms of + * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the Sonar Source-Available License for more details. + * + * You should have received a copy of the Sonar Source-Available License + * along with this program; if not, see https://sonarsource.com/license/ssal/ + */ +package org.sonar.plugins.java; + +import com.google.gson.Gson; +import com.google.gson.JsonIOException; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.sonar.api.batch.sensor.SensorContext; +import org.sonar.java.model.springcontext.SpringContextGatheringModel; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; + +final class SpringContextModelPersistence { + + private static final Logger LOG = LoggerFactory.getLogger(SpringContextModelPersistence.class); + private static final Gson GSON = new Gson(); + + private SpringContextModelPersistence() { + } + + static Optional configuredPath(SensorContext context, File rootDirectory) { + return context.config().get(JavaSensor.SPRING_CONTEXT_MODEL_PATH_PROPERTY) + .filter(value -> !value.isBlank()) + .map(value -> rootDirectory.toPath().resolve(value).toAbsolutePath().normalize()); + } + + static SpringContextGatheringModel load(Path path) { + if (!Files.exists(path)) { + return new SpringContextGatheringModel(); + } + try (var reader = Files.newBufferedReader(path, UTF_8)) { + SpringContextGatheringModel model = GSON.fromJson(reader, SpringContextGatheringModel.class); + if (model == null) { + throw new IllegalArgumentException("JSON document is empty"); + } + return model; + } catch (IOException | RuntimeException e) { + throw new IllegalStateException("Unable to load Spring context model from " + path, e); + } + } + + static void save(Path path, SpringContextGatheringModel model) { + try { + Path directory = path.toAbsolutePath().getParent(); + Files.createDirectories(directory); + Path temporaryFile = Files.createTempFile(directory, "spring-context-model-", ".tmp"); + try { + try (var writer = Files.newBufferedWriter(temporaryFile, UTF_8)) { + GSON.toJson(model, writer); + } + Files.move(temporaryFile, path, REPLACE_EXISTING); + } finally { + Files.deleteIfExists(temporaryFile); + } + } catch (IOException | JsonIOException e) { + LOG.warn("Unable to save Spring context model to {}", path, e); + } + } +} 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 6912c84abd9..4b3dd70c2a6 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; @@ -26,36 +27,39 @@ import org.sonar.api.scanner.sensor.ProjectSensor; import org.sonar.check.Rule; import org.sonar.java.GeneratedCheckList; +import org.sonar.java.SonarComponents; import org.sonar.java.checks.spring.SpringContextCheck; import org.sonar.java.checks.spring.SpringContextChecks; import org.sonar.java.checks.spring.SpringContextIssue; +import org.sonar.java.exceptions.ApiMismatchException; import org.sonar.java.jsp.Jasper; import org.sonar.java.model.springcontext.BeanLocation; +import org.sonar.java.model.springcontext.SpringContextGatheringModel; import org.sonar.java.model.springcontext.SpringContextModel; +import org.sonar.java.model.springcontext.SpringContextModelMetrics; import org.sonar.java.reporting.AnalyzerMessage; import org.sonar.java.telemetry.Telemetry; import org.sonar.java.telemetry.TelemetryKey; /** - * A post-phase {@link ProjectSensor} that holds the shared {@link SpringContextModel} built during analysis. + * A post-phase {@link ProjectSensor} that builds and checks the shared {@link SpringContextModel}. * *

This sensor runs after the main Java analysis phase, ensuring that all - * {@link org.sonar.java.model.springcontext.SpringContextModelGatherer} visitors have finished populating - * the model before it can be consumed by downstream components. + * {@link org.sonar.java.model.springcontext.SpringContextModelGatherer} visitors have finished collecting + * project data before the model is built and consumed by downstream components. * - *

The {@link SpringContextModel} instance is injected via the IoC container and shared across all - * components that need access to Spring context information (bean definitions, component-scan packages, etc.). + *

The model is built once from the complete gathering model before project checks run. */ @Phase(name = Phase.Name.POST) @DependedUpon(value = "CollectSpringContextBeforeSendingTelemetry") public class SpringContextModelSensor implements ProjectSensor { - private final SpringContextModel springContextModel; private final Telemetry telemetry; + private final SpringContextGatheringModel gatheringModel; - public SpringContextModelSensor(SpringContextModel springContextModel, Telemetry telemetry) { - this.springContextModel = springContextModel; + public SpringContextModelSensor(Telemetry telemetry, SpringContextGatheringModel gatheringModel) { this.telemetry = telemetry; + this.gatheringModel = gatheringModel; } @Override @@ -65,10 +69,22 @@ public void describe(SensorDescriptor descriptor) { @Override public void execute(SensorContext context) { + if (isFullProjectAnalysis(context)) { + gatheringModel.removeUnvisitedFiles(); + } + long buildingStartTime = System.nanoTime(); + SpringContextModel springContextModel = SpringContextModel.of(gatheringModel); + telemetry.aggregateAsCounter( + TelemetryKey.JAVA_SPRING_CONTEXT_MODEL_GATHERING_TIME_MS, + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - buildingStartTime)); + recordSpringTelemetry(springContextModel); + SpringContextModelPersistence.configuredPath(context, context.fileSystem().baseDir()) + .ifPresent(path -> SpringContextModelPersistence.save(path, gatheringModel)); + long startTime = System.nanoTime(); try { for (SpringContextCheck check : SpringContextChecks.getAllChecks()) { - reportIssues(context, check); + reportIssues(context, check, springContextModel); } } finally { telemetry.aggregateAsCounter( @@ -77,7 +93,15 @@ public void execute(SensorContext context) { } } - private void reportIssues(SensorContext context, SpringContextCheck check) { + private static boolean isFullProjectAnalysis(SensorContext context) { + try { + return context.runtime().getProduct() == SonarProduct.SONARQUBE && !SonarComponents.canSkipUnchangedFiles(context); + } catch (ApiMismatchException e) { + return false; + } + } + + private static void reportIssues(SensorContext context, SpringContextCheck check, SpringContextModel springContextModel) { RuleKey ruleKey = RuleKey.of(GeneratedCheckList.REPOSITORY_KEY, check.getClass().getAnnotation(Rule.class).key()); if (context.activeRules().find(ruleKey) == null) { // Rule not active in the quality profile: skip running the check, its issues would be discarded anyway. @@ -85,6 +109,9 @@ private void reportIssues(SensorContext context, SpringContextCheck check) { } for (SpringContextIssue issue : check.execute(springContextModel)) { BeanLocation location = issue.location(); + if (location.inputFile() == null) { + continue; + } AnalyzerMessage.TextSpan span = location.mainLocation(); NewIssue newIssue = context.newIssue().forRule(ruleKey); newIssue.at(newIssue.newLocation() @@ -94,4 +121,13 @@ private void reportIssues(SensorContext context, SpringContextCheck check) { newIssue.save(); } } + + private void recordSpringTelemetry(SpringContextModel model) { + var metrics = SpringContextModelMetrics.of(model); + telemetry.aggregateAsCounter(TelemetryKey.JAVA_SPRING_BEAN_COUNT, metrics.beanCount()); + telemetry.aggregateAsCounter(TelemetryKey.JAVA_SPRING_BEAN_NAME_COUNT, metrics.beanNameCount()); + telemetry.aggregateAsCounter(TelemetryKey.JAVA_SPRING_INJECTION_POINT_COUNT, metrics.injectionPointCount()); + telemetry.aggregateAsCounter(TelemetryKey.JAVA_SPRING_COMPONENT_SCAN_PACKAGE_COUNT, metrics.componentScanPackageCount()); + telemetry.aggregateAsCounter(TelemetryKey.JAVA_SPRING_CONTEXT_MODEL_SIZE_BYTES, metrics.estimatedSizeInBytes()); + } } 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 7d3719dc612..b1318e3f56e 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 @@ -24,6 +24,7 @@ import org.sonar.api.SonarRuntime; import org.sonar.api.utils.Version; import org.sonar.java.jsp.Jasper; +import org.sonar.java.model.springcontext.SpringContextGatheringModel; import org.sonar.plugins.java.api.caching.SonarLintCache; import static org.assertj.core.api.Assertions.assertThat; @@ -40,8 +41,8 @@ void sonarLint_9_9_extensions() { Plugin.Context context = new Plugin.Context(runtime); javaPlugin.define(context); assertThat(context.getExtensions()) - .hasSize(22) - .contains(SonarLintCache.class); + .hasSize(23) + .contains(SonarLintCache.class, SpringContextGatheringModel.class); } @@ -51,7 +52,8 @@ void sonarqube_9_9_extensions() { Plugin.Context context = new Plugin.Context(sqCommunity); javaPlugin.define(context); assertThat(context.getExtensions()) - .hasSize(39) + .hasSize(40) + .contains(SpringContextGatheringModel.class) .doesNotContain(Jasper.class); } @@ -61,8 +63,8 @@ void sonarqube_9_9_commercial_extensions() { Plugin.Context context = new Plugin.Context(sqEnterprise); javaPlugin.define(context); assertThat(context.getExtensions()) - .hasSize(40) - .contains(Jasper.class); + .hasSize(41) + .contains(Jasper.class, SpringContextGatheringModel.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 2c62642722c..f635d5f380c 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 @@ -58,6 +58,7 @@ import org.sonar.java.jsp.Jasper; import org.sonar.java.model.GeneratedFile; import org.sonar.java.model.JavaVersionImpl; +import org.sonar.java.model.springcontext.SpringContextGatheringModel; import org.sonar.java.reporting.AnalyzerMessage; import org.sonar.java.telemetry.DefaultTelemetry; import org.sonar.java.telemetry.Telemetry; @@ -90,7 +91,7 @@ class JavaSensorTest { - private static final String EXPECTED_TYPE_ERROR_COUNT = "205"; + private static final String EXPECTED_TYPE_ERROR_COUNT = "208"; private static final CheckFactory checkFactory = mock(CheckFactory.class); private static final Checks checks = mock(Checks.class); @@ -111,7 +112,7 @@ class JavaSensorTest { @Test void test_toString() throws IOException { SonarComponents sonarComponents = createSonarComponentsMock(createContext(InputFile.Type.MAIN)); - assertThat(new JavaSensor(sonarComponents, null, null, null, null, telemetry, null)).hasToString("JavaSensor"); + assertThat(new JavaSensor(sonarComponents, null, null, null, null, telemetry, new SpringContextGatheringModel())).hasToString("JavaSensor"); } @Test @@ -179,7 +180,7 @@ private void testIssueCreation(InputFile.Type onType, int expectedIssues) throws SonarComponents sonarComponents = createSonarComponentsMock(context); DefaultJavaResourceLocator javaResourceLocator = createDefaultJavaResourceLocator(settings.asConfig(), fs); - JavaSensor jss = new JavaSensor(sonarComponents, javaResourceLocator, noSonarFilter, null, telemetry, null); + JavaSensor jss = new JavaSensor(sonarComponents, javaResourceLocator, noSonarFilter, null, telemetry, new SpringContextGatheringModel()); jss.execute(context); int expectedNoSonarLine = lineNumberOfTheMethodWithNoSonar(fs); @@ -281,7 +282,7 @@ private void assertJasperIsInvoked(MapSettings settings) throws IOException { Jasper jasper = mock(Jasper.class); when(jasper.generateFiles(any(), any())).thenReturn(asList(generatedFile)); JavaSensor jss = new JavaSensor(sonarComponents, mock(JavaResourceLocator.class), - mock(NoSonarFilter.class), null, jasper, telemetry, null); + mock(NoSonarFilter.class), null, jasper, telemetry, new SpringContextGatheringModel()); jss.execute(context); ArgumentCaptor scannerContext = ArgumentCaptor.forClass(JavaFileScannerContext.class); @@ -309,7 +310,7 @@ void should_not_invoke_jasper_jsp_compilation_in_autoscan_for_security_reasons() Jasper jasper = mock(Jasper.class); JavaSensor jss = new JavaSensor(sonarComponents, mock(JavaResourceLocator.class), - mock(NoSonarFilter.class), null, jasper, telemetry, null); + mock(NoSonarFilter.class), null, jasper, telemetry, new SpringContextGatheringModel()); jss.execute(context); verify(jasper, never()).generateFiles(any(), any()); @@ -501,7 +502,7 @@ void custom_file_scanner_is_not_filtered_in_autoscan() throws IOException { SonarComponents components = new SonarComponents(fileLinesContextFactory, fs, javaClasspath, javaTestClasspath, specificCheckFactory, context.activeRules(), checkRegistrars, null, null); - JavaSensor jss = new JavaSensor(components, resourceLocator, mock(NoSonarFilter.class), null, telemetry, null); + JavaSensor jss = new JavaSensor(components, resourceLocator, mock(NoSonarFilter.class), null, telemetry, new SpringContextGatheringModel()); jss.execute(context); assertThat(hook.scanFileCount).as("Custom file scanner should be called even in autoscan mode").isPositive(); @@ -516,7 +517,7 @@ void custom_file_scanner_is_not_filtered_in_autoscan() throws IOException { void test_describe_sensor() throws IOException { DefaultSensorDescriptor descriptor = new DefaultSensorDescriptor(); SonarComponents sonarComponents = createSonarComponentsMock(createContext(InputFile.Type.MAIN)); - var sensor = new JavaSensor(sonarComponents, null, null, null, telemetry, null); + var sensor = new JavaSensor(sonarComponents, null, null, null, telemetry, new SpringContextGatheringModel()); sensor.describe(descriptor); assertThat(descriptor.name()).isEqualTo("JavaSensor"); assertThat(descriptor.languages()).containsExactly("java", "jsp"); @@ -563,7 +564,7 @@ private SensorContextTester analyzeTwoFilesWithIssues(MapSettings settings) thro SonarComponents components = new SonarComponents(fileLinesContextFactory, fs, javaClasspath, javaTestClasspath, specificCheckFactory, context.activeRules(), checkRegistrars, null, null); - JavaSensor jss = new JavaSensor(components, resourceLocator, mock(NoSonarFilter.class), null, telemetry, null); + JavaSensor jss = new JavaSensor(components, resourceLocator, mock(NoSonarFilter.class), null, telemetry, new SpringContextGatheringModel()); jss.execute(context); return context; } @@ -576,7 +577,7 @@ private void executeJavaSensorForPerformanceMeasure(MapSettings settings, Path w fs.setWorkDir(workDir); SonarComponents components = createSonarComponentsMock(context); DefaultJavaResourceLocator resourceLocator = createDefaultJavaResourceLocator(context.config(), fs); - JavaSensor jss = new JavaSensor(components, resourceLocator, mock(NoSonarFilter.class), null, telemetry, null); + JavaSensor jss = new JavaSensor(components, resourceLocator, mock(NoSonarFilter.class), null, telemetry, new SpringContextGatheringModel()); jss.execute(context); } diff --git a/sonar-java-plugin/src/test/java/org/sonar/plugins/java/ProjectEndOfAnalysisSensorTest.java b/sonar-java-plugin/src/test/java/org/sonar/plugins/java/ProjectEndOfAnalysisSensorTest.java index 471344515a2..1dbb0ca842d 100644 --- a/sonar-java-plugin/src/test/java/org/sonar/plugins/java/ProjectEndOfAnalysisSensorTest.java +++ b/sonar-java-plugin/src/test/java/org/sonar/plugins/java/ProjectEndOfAnalysisSensorTest.java @@ -23,11 +23,6 @@ import org.junit.jupiter.api.io.TempDir; import org.slf4j.event.Level; import org.sonar.api.testfixtures.log.LogTesterJUnit5; -import org.sonar.java.model.springcontext.BeanDefinitionHolder; -import org.sonar.java.model.springcontext.BeanLocation; -import org.sonar.java.model.springcontext.ProfileExpression; -import org.sonar.java.model.springcontext.SpringContextModel; -import org.sonar.java.reporting.AnalyzerMessage; import org.sonar.java.telemetry.DefaultTelemetry; import org.sonar.java.telemetry.NoOpTelemetry; import org.sonar.java.telemetry.TelemetryKey; @@ -42,7 +37,7 @@ class ProjectEndOfAnalysisSensorTest { @Test void test_describe() { - var sensor = new ProjectEndOfAnalysisSensor(new NoOpTelemetry(), new SpringContextModel()); + var sensor = new ProjectEndOfAnalysisSensor(new NoOpTelemetry()); DefaultSensorDescriptor descriptor = new DefaultSensorDescriptor(); sensor.describe(descriptor); assertThat(descriptor.name()).isEqualTo("JavaProjectSensor"); @@ -57,51 +52,13 @@ void test_telemetry(@TempDir Path tempDir) { telemetry.aggregateAsSortedSet(TelemetryKey.JAVA_MODULE_COUNT, "3"); telemetry.aggregateAsCounter(TelemetryKey.JAVA_SPRING_CONTEXT_MODEL_GATHERING_TIME_MS, 12L); telemetry.aggregateAsCounter(TelemetryKey.JAVA_SPRING_CONTEXT_CHECKS_TIME_MS, 34L); - var springContextModel = new SpringContextModel(); - var sensor = new ProjectEndOfAnalysisSensor(telemetry, springContextModel); + var sensor = new ProjectEndOfAnalysisSensor(telemetry); SensorContextTester context = SensorContextTester.create(tempDir); sensor.execute(context); - String contextModelSize = context.getTelemetryProperties().get("java.spring.context_model_size_bytes"); - assertThat(Long.parseLong(contextModelSize)).isPositive(); assertThat(logTester.logs(Level.DEBUG)).containsExactly( "Telemetry java.language.version: 21", "Telemetry java.module_count: 3", - "Telemetry java.spring.bean_count: 0", - "Telemetry java.spring.bean_name_count: 0", - "Telemetry java.spring.component_scan_package_count: 0", "Telemetry java.spring.context_checks_time_ms: 34", - "Telemetry java.spring.context_model_gathering_time_ms: 12", - "Telemetry java.spring.context_model_size_bytes: " + contextModelSize, - "Telemetry java.spring.injection_point_count: 0"); - } - - @Test - void test_spring_telemetry(@TempDir Path tempDir) { - var springContextModel = new SpringContextModel(); - springContextModel.getBeanDefinitionRegistry().addBeanDefinition("myBean", newHolder("com.acme.MyBean")); - springContextModel.getBeanDefinitionRegistry().addBeanDefinition("myOtherBean", newHolder("com.acme.MyOtherBean")); - springContextModel.getTypeToDependenciesIndex().addDependencyForType("com.acme.MyBean", "myBean", "module-a", ProfileExpression.UNCONDITIONAL, newLocation(), false); - springContextModel.getProjectPackageScan().addPackage("module-a", "com.acme"); - - var sensor = new ProjectEndOfAnalysisSensor(new DefaultTelemetry(), springContextModel); - SensorContextTester context = SensorContextTester.create(tempDir); - sensor.execute(context); - - assertThat(context.getTelemetryProperties()) - .containsEntry("java.spring.bean_count", "2") - .containsEntry("java.spring.bean_name_count", "2") - .containsEntry("java.spring.injection_point_count", "1") - .containsEntry("java.spring.component_scan_package_count", "1") - .containsEntry("java.spring.context_model_gathering_time_ms", "0") - .containsEntry("java.spring.context_checks_time_ms", "0"); - assertThat(Long.parseLong(context.getTelemetryProperties().get("java.spring.context_model_size_bytes"))).isPositive(); - } - - private static BeanDefinitionHolder newHolder(String type) { - return new BeanDefinitionHolder.Builder(type, "module-a", "com.acme", newLocation()).build(); - } - - private static BeanLocation newLocation() { - return new BeanLocation(null, new AnalyzerMessage.TextSpan(1)); + "Telemetry java.spring.context_model_gathering_time_ms: 12"); } } 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 cd0979f4472..f4703b9224c 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 @@ -16,37 +16,68 @@ */ package org.sonar.plugins.java; +import com.google.gson.Gson; import com.sonarsource.scanner.engine.sensor.test.fixtures.SensorContextTester; import com.sonarsource.scanner.engine.sensor.test.fixtures.TestInputFileBuilder; -import java.io.File; +import com.sonarsource.scanner.engine.sensor.test.fixtures.TestSonarRuntime; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; 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.api.batch.bootstrap.ProjectDefinition; import org.sonar.api.batch.fs.InputFile; import org.sonar.api.batch.rule.ActiveRules; import org.sonar.api.batch.sensor.issue.Issue; +import org.sonar.api.issue.NoSonarFilter; import org.sonar.api.rule.RuleKey; +import org.sonar.api.testfixtures.log.LogTesterJUnit5; +import org.sonar.api.utils.Version; +import org.sonar.java.SonarComponents; import org.sonar.java.model.springcontext.BeanDefinitionHolder; -import org.sonar.java.model.springcontext.BeanLocation; +import org.sonar.java.model.springcontext.InjectionPoint; import org.sonar.java.model.springcontext.ProfileExpression; +import org.sonar.java.model.springcontext.SpringContextGatheringModel; import org.sonar.java.model.springcontext.SpringContextModel; import org.sonar.java.reporting.AnalyzerMessage.TextSpan; import org.sonar.java.telemetry.DefaultTelemetry; import org.sonar.java.telemetry.NoOpTelemetry; +import org.sonar.java.telemetry.Telemetry; +import org.sonar.plugins.java.api.JavaResourceLocator; +import org.sonar.scanner.plugin.api.impl.config.MapSettings; import org.sonar.scanner.plugin.api.impl.rule.ActiveRulesBuilder; import org.sonar.scanner.plugin.api.impl.rule.NewActiveRule; import org.sonar.scanner.plugin.api.impl.sensor.DefaultSensorDescriptor; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; class SpringContextModelSensorTest { private static final String MODULE_KEY = "module"; - private static final String PACKAGE = "checks.spring.s9352"; + private static final String MODEL_PATH = "state/context.json"; private static final RuleKey S9352_RULE_KEY = RuleKey.of("java", "S9352"); + private static final Gson GSON = new Gson(); + + @TempDir + Path tempDir; + + @RegisterExtension + public LogTesterJUnit5 logTester = new LogTesterJUnit5(); @Test void test_toString() { DefaultSensorDescriptor descriptor = new DefaultSensorDescriptor(); - SpringContextModelSensor sensor = new SpringContextModelSensor(new SpringContextModel(), new NoOpTelemetry()); + SpringContextModelSensor sensor = sensor(new SpringContextGatheringModel(), new NoOpTelemetry()); sensor.describe(descriptor); assertThat(descriptor.name()).isEqualTo("Java SpringContextModelSensor"); assertThat(descriptor.languages()).containsExactly("java", "jsp"); @@ -54,18 +85,18 @@ void test_toString() { @Test void reports_an_issue_for_an_ambiguous_dependency() { - SensorContextTester context = SensorContextTester.create(new File("")); + SensorContextTester context = SensorContextTester.create(tempDir); context.setActiveRules(activeRulesWithS9352()); - SpringContextModel model = new SpringContextModel(); + var gatheringModel = new SpringContextGatheringModel(); InputFile inputFile = fakeInputFile(context, "UnresolvedConsumer.java"); String type = "org.springframework.context.ApplicationContextAware"; - registerBean(model, type, "componentOne", inputFile, 5, 0, 5, 12); - registerBean(model, type, "componentTwo", inputFile, 6, 0, 6, 12); - registerDependency(model, type, "contextAware", inputFile, 13, 13, 13, 25); + registerBean(gatheringModel, type, "componentOne", inputFile); + registerBean(gatheringModel, type, "componentTwo", inputFile); + registerDependency(gatheringModel, type, "contextAware", inputFile); var telemetry = new DefaultTelemetry(); - new SpringContextModelSensor(model, telemetry).execute(context); + sensor(gatheringModel, telemetry).execute(context); assertThat(context.allIssues()).hasSize(1); Issue issue = context.allIssues().iterator().next(); @@ -79,41 +110,315 @@ void reports_an_issue_for_an_ambiguous_dependency() { @Test void does_not_report_issues_when_rule_is_not_active() { - SensorContextTester context = SensorContextTester.create(new File("")); + SensorContextTester context = SensorContextTester.create(tempDir); // No active rules registered: S9352 is not in the quality profile. - SpringContextModel model = new SpringContextModel(); + var gatheringModel = new SpringContextGatheringModel(); InputFile inputFile = fakeInputFile(context, "UnresolvedConsumer.java"); String type = "org.springframework.context.ApplicationContextAware"; - registerBean(model, type, "componentOne", inputFile, 5, 0, 5, 12); - registerBean(model, type, "componentTwo", inputFile, 6, 0, 6, 12); - registerDependency(model, type, "contextAware", inputFile, 13, 13, 13, 25); + registerBean(gatheringModel, type, "componentOne", inputFile); + registerBean(gatheringModel, type, "componentTwo", inputFile); + registerDependency(gatheringModel, type, "contextAware", inputFile); var telemetry = new DefaultTelemetry(); - new SpringContextModelSensor(model, telemetry).execute(context); + sensor(gatheringModel, telemetry).execute(context); assertThat(context.allIssues()).isEmpty(); assertThat(telemetry.toMap().get("java.spring.context_checks_time_ms")).matches("\\d+"); } + @Test + void restored_beans_affect_analyzed_files_without_reporting_on_restored_files() { + SensorContextTester context = SensorContextTester.create(tempDir); + context.setActiveRules(activeRulesWithS9352()); + MapSettings settings = new MapSettings(); + settings.setProperty(SonarComponents.SONAR_CAN_SKIP_UNCHANGED_FILES_KEY, true); + settings.setProperty(JavaSensor.SPRING_CONTEXT_MODEL_PATH_PROPERTY, MODEL_PATH); + context.setSettings(settings); + InputFile analyzedFile = fakeInputFile(context, "Consumer.java"); + String type = "example.Service"; + var gatheringModel = new SpringContextGatheringModel(); + gatheringModel.collectBeans(MODULE_KEY, "restored-one", null, List.of(gatheredBean("componentOne", type, Map.of()))); + gatheringModel.collectBeans(MODULE_KEY, "restored-two", null, List.of(gatheredBean("componentTwo", type, Map.of()))); + var dependency = Map.of(type, Set.of(new InjectionPoint.InputFileData("contextAware", new TextSpan(13, 13, 13, 25), false))); + gatheringModel.collectBeans(MODULE_KEY, "restored-consumer", null, List.of(gatheredBean("oldConsumer", "example.OldConsumer", dependency))); + gatheringModel.collectBeans(MODULE_KEY, analyzedFile.key(), analyzedFile, List.of(gatheredBean("consumer", "example.Consumer", dependency))); + + sensor(gatheringModel, new NoOpTelemetry()).execute(context); + + assertThat(context.allIssues()).hasSize(1); + assertThat(context.allIssues().iterator().next().primaryLocation().inputComponent()).isEqualTo(analyzedFile); + var savedModel = SpringContextModelPersistence.load(tempDir.resolve(MODEL_PATH)); + assertThat(savedModel.filesData().get(MODULE_KEY)).hasSize(4); + } + + @Test + void removes_unvisited_files_before_building_and_saving_the_model_on_full_analysis() { + SensorContextTester context = configuredContext(); + context.setActiveRules(activeRulesWithS9352()); + InputFile analyzedFile = fakeInputFile(context, "Consumer.java"); + String type = "example.Service"; + var gatheringModel = new SpringContextGatheringModel(); + gatheringModel.collectBeans(MODULE_KEY, "unvisited", null, List.of(gatheredBean("oldService", type, Map.of()))); + gatheringModel.collectPackages(MODULE_KEY, "unvisited", null, Set.of("stale.package")); + registerBean(gatheringModel, type, "currentService", analyzedFile); + registerDependency(gatheringModel, type, "contextAware", analyzedFile); + + var telemetry = new DefaultTelemetry(); + sensor(gatheringModel, telemetry).execute(context); + + assertThat(context.allIssues()).isEmpty(); + assertThat(gatheringModel.filesData().get(MODULE_KEY)).containsOnlyKeys("currentService", "consumer"); + assertThat(telemetry.toMap()) + .containsEntry("java.spring.bean_count", "2") + .containsEntry("java.spring.component_scan_package_count", "0"); + var savedModel = SpringContextModelPersistence.load(tempDir.resolve(MODEL_PATH)); + assertThat(savedModel.filesData().get(MODULE_KEY)).containsOnlyKeys("currentService", "consumer"); + } + + @Test + void keeps_unvisited_files_for_incremental_and_sonarlint_analyses() { + SensorContextTester incrementalContext = SensorContextTester.create(tempDir); + MapSettings settings = new MapSettings(); + settings.setProperty(SonarComponents.SONAR_CAN_SKIP_UNCHANGED_FILES_KEY, true); + settings.setProperty(JavaSensor.SPRING_CONTEXT_MODEL_PATH_PROPERTY, MODEL_PATH); + incrementalContext.setSettings(settings); + SensorContextTester sonarLintContext = SensorContextTester.create(tempDir) + .setRuntime(TestSonarRuntime.forSonarLint(Version.create(6, 7))); + sonarLintContext.setSettings(new MapSettings().setProperty(JavaSensor.SPRING_CONTEXT_MODEL_PATH_PROPERTY, MODEL_PATH)); + + for (SensorContextTester context : List.of(incrementalContext, sonarLintContext)) { + var gatheringModel = new SpringContextGatheringModel(); + gatheringModel.collectBeans(MODULE_KEY, "unvisited", null, List.of(gatheredBean("oldService", "example.Service", Map.of()))); + gatheringModel.collectPackages(MODULE_KEY, "unvisited", null, Set.of("stale.package")); + + sensor(gatheringModel, new NoOpTelemetry()).execute(context); + + assertThat(gatheringModel.filesData().get(MODULE_KEY)).containsOnlyKeys("unvisited"); + var savedModel = SpringContextModelPersistence.load(tempDir.resolve(MODEL_PATH)); + assertThat(savedModel.filesData().get(MODULE_KEY)).containsOnlyKeys("unvisited"); + } + } + + @Test + void reports_issues_when_saving_fails() throws IOException { + SensorContextTester context = configuredContext(); + context.setSettings(new MapSettings().setProperty(JavaSensor.SPRING_CONTEXT_MODEL_PATH_PROPERTY, "blocked/context.json")); + context.setActiveRules(activeRulesWithS9352()); + Files.writeString(tempDir.resolve("blocked"), "file"); + var gatheringModel = new SpringContextGatheringModel(); + InputFile inputFile = fakeInputFile(context, "UnresolvedConsumer.java"); + String type = "org.springframework.context.ApplicationContextAware"; + registerBean(gatheringModel, type, "componentOne", inputFile); + registerBean(gatheringModel, type, "componentTwo", inputFile); + registerDependency(gatheringModel, type, "contextAware", inputFile); + + sensor(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 ")); + } + + @Test + void keeps_unvisited_files_when_scan_mode_cannot_be_determined() { + SensorContextTester context = spy(SensorContextTester.create(tempDir)); + doThrow(new NoSuchMethodError("canSkipUnchangedFiles is unavailable")).when(context).canSkipUnchangedFiles(); + var gatheringModel = new SpringContextGatheringModel(); + gatheringModel.collectBeans(MODULE_KEY, "unvisited", null, List.of(gatheredBean("oldService", "example.Service", Map.of()))); + + sensor(gatheringModel, new NoOpTelemetry()).execute(context); + + assertThat(gatheringModel.filesData().get(MODULE_KEY)).containsOnlyKeys("unvisited"); + } + private static ActiveRules activeRulesWithS9352() { return new ActiveRulesBuilder() .addRule(new NewActiveRule.Builder().setRuleKey(S9352_RULE_KEY).build()) .build(); } - private static void registerBean(SpringContextModel model, String type, String beanName, InputFile inputFile, - int startLine, int startCharacter, int endLine, int endCharacter) { - BeanLocation location = new BeanLocation(inputFile, new TextSpan(startLine, startCharacter, endLine, endCharacter)); - model.getBeanDefinitionRegistry().addBeanDefinition(beanName, - new BeanDefinitionHolder.Builder(type, MODULE_KEY, PACKAGE, location).build()); - model.getTypeToBeansIndex().addBeanForType(type, beanName, MODULE_KEY, PACKAGE); + @Test + void records_metrics_for_the_built_model() { + SensorContextTester context = SensorContextTester.create(tempDir); + InputFile inputFile = fakeInputFile(context, "Consumer.java"); + var gatheringModel = new SpringContextGatheringModel(); + String type = "example.Service"; + registerBean(gatheringModel, type, "componentOne", inputFile); + registerBean(gatheringModel, type, "componentTwo", inputFile); + registerDependency(gatheringModel, type, "contextAware", inputFile); + gatheringModel.collectPackages(MODULE_KEY, "componentOne", inputFile, Set.of("example")); + + var telemetry = new DefaultTelemetry(); + sensor(gatheringModel, telemetry).execute(context); + + assertThat(telemetry.toMap()) + .containsEntry("java.spring.bean_count", "3") + .containsEntry("java.spring.bean_name_count", "3") + .containsEntry("java.spring.injection_point_count", "1") + .containsEntry("java.spring.component_scan_package_count", "1"); + assertThat(Long.parseLong(telemetry.toMap().get("java.spring.context_model_size_bytes"))).isPositive(); + assertThat(telemetry.toMap().get("java.spring.context_model_gathering_time_ms")).matches("\\d+"); + assertThat(telemetry.toMap().get("java.spring.context_checks_time_ms")).matches("\\d+"); + } + + @Test + void restores_updates_and_saves_one_model_across_module_contexts_during_incremental_analysis() throws IOException { + Path moduleA = Files.createDirectory(tempDir.resolve("module-a")); + Path moduleB = Files.createDirectory(tempDir.resolve("module-b")); + Path modelPath = tempDir.resolve(MODEL_PATH); + Files.createDirectories(modelPath.getParent()); + + InputFile analyzedFile = new TestInputFileBuilder("module-a", "src/A.java").setContents("class A {}").build(); + String analyzedFileKey = analyzedFile.key(); + String untouchedFileKey = "module-b:src/B.java"; + var previousBean = bean("previous"); + var untouchedBean = bean("untouched"); + var initialModel = new SpringContextGatheringModel(); + initialModel.collectBeans("module-a", analyzedFileKey, null, List.of(previousBean)); + initialModel.collectPackages("module-a", analyzedFileKey, null, Set.of("previous.package")); + initialModel.collectBeans("module-b", untouchedFileKey, null, List.of(untouchedBean)); + initialModel.collectPackages("module-b", untouchedFileKey, null, Set.of("untouched.package")); + Files.writeString(modelPath, GSON.toJson(initialModel)); + + var gatheringModel = new SpringContextGatheringModel(); + JavaSensor sensor = javaSensor(gatheringModel); + sensor.execute(javaSensorContext(moduleA, MODEL_PATH, true)); + assertThat(gatheringModel.filesData()).containsOnlyKeys("module-a", "module-b"); + + var updatedBean = bean("updated"); + gatheringModel.collectBeans("module-a", analyzedFileKey, analyzedFile, List.of(updatedBean)); + gatheringModel.collectPackages("module-a", analyzedFileKey, analyzedFile, Set.of("updated.package")); + + Files.writeString(modelPath, "{"); + sensor.execute(javaSensorContext(moduleB, MODEL_PATH, true)); + assertThat(SpringContextModelPersistence.configuredPath(javaSensorContext(moduleB, MODEL_PATH, false), tempDir.toFile())).contains(modelPath); + assertThat(gatheringModel.filesData().get("module-a").get(analyzedFileKey).inputFile()).isSameAs(analyzedFile); + SpringContextModel projectModel = SpringContextModel.of(gatheringModel); + assertThat(projectModel.getBeanDefinitionRegistry().getByName("previous")).isEmpty(); + assertThat(projectModel.getBeanDefinitionRegistry().getByName("updated")).hasSize(1); + 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, true)); + + SpringContextGatheringModel saved = GSON.fromJson(Files.readString(modelPath), SpringContextGatheringModel.class); + assertThat(saved.filesData()).containsOnlyKeys("module-a", "module-b"); + assertThat(saved.filesData().get("module-a").get(analyzedFileKey).beans()).containsExactly(updatedBean); + assertThat(saved.filesData().get("module-a").get(analyzedFileKey).packages()).containsExactly("updated.package"); + assertThat(saved.filesData().get("module-b").get(untouchedFileKey).beans()).containsExactly(untouchedBean); + assertThat(saved.filesData().get("module-b").get(untouchedFileKey).packages()).containsExactly("untouched.package"); + } + + @Test + void does_not_restore_from_the_default_path_when_the_property_is_unset_or_blank() throws IOException { + Path defaultPath = tempDir.resolve(".sonar/spring-context-model.json"); + Files.createDirectories(defaultPath.getParent()); + var previousModel = new SpringContextGatheringModel(); + previousModel.collectBeans("previous-module", "previous-file", null, List.of(bean("previous"))); + Files.writeString(defaultPath, GSON.toJson(previousModel)); + + for (SensorContextTester analysisContext : List.of(javaSensorContext(tempDir), javaSensorContext(tempDir, " ", false))) { + var gatheringModel = new SpringContextGatheringModel(); + javaSensor(gatheringModel).execute(analysisContext); + assertThat(gatheringModel.isRestored()).isFalse(); + assertThat(gatheringModel.filesData()).doesNotContainKey("previous-module"); + } + } + + @Test + void resolves_the_configured_path_from_the_root_project() throws IOException { + Path module = Files.createDirectory(tempDir.resolve("module-a")); + SensorContextTester moduleContext = javaSensorContext(module, MODEL_PATH, false); + ProjectDefinition rootProject = ProjectDefinition.create(); + rootProject.setBaseDir(tempDir.toFile()); + ProjectDefinition moduleProject = ProjectDefinition.create(); + moduleProject.setBaseDir(module.toFile()); + rootProject.addSubProject(moduleProject); + SonarComponents components = new SonarComponents(null, moduleContext.fileSystem(), null, null, null, null, moduleProject); + + assertThat(components.projectLevelBaseDir()).isEqualTo(tempDir.toFile()); + assertThat(SpringContextModelPersistence.configuredPath(moduleContext, components.projectLevelBaseDir())) + .contains(tempDir.resolve(MODEL_PATH)); + } + + @Test + void rejects_invalid_json_without_overwriting_it() throws IOException { + Path modelPath = tempDir.resolve(MODEL_PATH); + Files.createDirectories(modelPath.getParent()); + Files.writeString(modelPath, "{"); + + assertThatThrownBy(() -> SpringContextModelPersistence.load(modelPath)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(modelPath.toString()); + assertThat(Files.readString(modelPath)).isEqualTo("{"); + } + + @Test + void saves_a_new_model_in_a_new_directory() { + Path modelPath = tempDir.resolve(MODEL_PATH); + + SpringContextModelPersistence.save(modelPath, new SpringContextGatheringModel()); + + assertThat(modelPath).exists(); + assertThat(SpringContextModelPersistence.load(modelPath).filesData()).isEmpty(); + } + + private SpringContextModelSensor sensor(SpringContextGatheringModel gatheringModel, Telemetry telemetry) { + return new SpringContextModelSensor(telemetry, gatheringModel); + } + + private SensorContextTester configuredContext() { + SensorContextTester context = SensorContextTester.create(tempDir); + context.setSettings(new MapSettings().setProperty(JavaSensor.SPRING_CONTEXT_MODEL_PATH_PROPERTY, MODEL_PATH)); + return context; + } + + private JavaSensor javaSensor(SpringContextGatheringModel gatheringModel) { + SonarComponents components = mock(SonarComponents.class); + when(components.projectLevelBaseDir()).thenReturn(tempDir.toFile()); + 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.getJavaTestClasspath()).thenReturn(List.of()); + when(components.getJspClasspath()).thenReturn(List.of()); + return new JavaSensor(components, mock(JavaResourceLocator.class), mock(NoSonarFilter.class), null, new NoOpTelemetry(), gatheringModel); + } + + private static SensorContextTester javaSensorContext(Path baseDirectory) { + SensorContextTester context = SensorContextTester.create(baseDirectory); + context.fileSystem().setWorkDir(baseDirectory); + return context; + } + + private static SensorContextTester javaSensorContext(Path baseDirectory, String configuredPath, boolean incremental) { + SensorContextTester context = javaSensorContext(baseDirectory); + MapSettings settings = new MapSettings(); + settings.setProperty(JavaSensor.SPRING_CONTEXT_MODEL_PATH_PROPERTY, configuredPath); + if (incremental) { + settings.setProperty(SonarComponents.SONAR_CAN_SKIP_UNCHANGED_FILES_KEY, true); + } + context.setSettings(settings); + return context; + } + + private static BeanDefinitionHolder.InputFileData bean(String name) { + return new BeanDefinitionHolder.InputFileData(name, "example.Bean", "example", new TextSpan(1, 0, 1, 4), + false, ProfileExpression.UNCONDITIONAL, null, Map.of(), Set.of("example.Bean")); + } + + private static BeanDefinitionHolder.InputFileData gatheredBean(String name, String type, Map> dependencies) { + return new BeanDefinitionHolder.InputFileData(name, type, "example", new TextSpan(5, 0, 5, 4), false, + ProfileExpression.UNCONDITIONAL, null, dependencies, Set.of(type)); + } + + private static void registerBean(SpringContextGatheringModel model, String type, String beanName, InputFile inputFile) { + model.collectBeans(MODULE_KEY, beanName, inputFile, List.of(gatheredBean(beanName, type, Map.of()))); } - private static void registerDependency(SpringContextModel model, String type, String dependencyName, InputFile inputFile, - int startLine, int startCharacter, int endLine, int endCharacter) { - BeanLocation location = new BeanLocation(inputFile, new TextSpan(startLine, startCharacter, endLine, endCharacter)); - model.getTypeToDependenciesIndex().addDependencyForType(type, dependencyName, MODULE_KEY, ProfileExpression.UNCONDITIONAL, location, false); + private static void registerDependency(SpringContextGatheringModel model, String type, String dependencyName, InputFile inputFile) { + var dependencies = Map.of(type, Set.of(new InjectionPoint.InputFileData(dependencyName, new TextSpan(13, 13, 13, 25), false))); + model.collectBeans(MODULE_KEY, "consumer", inputFile, List.of(gatheredBean("consumer", "example.Consumer", dependencies))); } private static InputFile fakeInputFile(SensorContextTester context, String fileName) {