From 700b5befd9bcb4f84279a99345ab963361ba7379 Mon Sep 17 00:00:00 2001 From: asya-vorobeva Date: Wed, 10 Jun 2026 10:34:00 +0200 Subject: [PATCH 1/2] SONARJAVA-6413 Implement BeanDefinitionGatherer to collect Spring bean definitions - Add BeanDefinitionGatherer that discovers beans from stereotype annotations (@Component, @Service, @Repository, @Controller, @RestController, @Configuration) and @Bean methods inside configuration/component classes - Capture @Primary flag and dependencies (@Autowired fields, constructors, setters; @Bean method parameters) - Register BeanDefinitionGatherer in SpringContextModelGatherers Co-Authored-By: Claude Sonnet 4.6 --- .../checks/MismatchPackageDirectoryCheck.java | 2 +- .../java/checks/StaticMemberAccessCheck.java | 2 +- .../java/checks/UndocumentedApiCheck.java | 2 +- .../checks/naming/BadPackageNameCheck.java | 2 +- .../SpringBeansShouldBeAccessibleCheck.java | 15 +- .../springcontext/BeanDefinitionGatherer.java | 213 ++++++++++++++++++ .../ComponentScanPackageGatherer.java | 15 +- .../SpringContextModelGatherers.java | 3 +- .../java/{model => utils}/PackageUtils.java | 41 ++-- .../org/sonar/java/utils/SpringUtils.java | 9 + .../AutowiredConstructorDependencies.java | 19 ++ .../springcontext/AutowiredDependencies.java | 16 ++ .../BeanMethodWithDependencies.java | 15 ++ .../ConfigurationWithBeanMethods.java | 29 +++ .../springcontext/ExplicitNameComponent.java | 7 + .../test/files/springcontext/PrimaryBean.java | 9 + .../files/springcontext/SimpleComponent.java | 7 + .../springcontext/SimpleConfiguration.java | 7 + .../files/springcontext/SimpleController.java | 7 + .../files/springcontext/SimpleRepository.java | 7 + .../springcontext/SimpleRestController.java | 7 + .../files/springcontext/SimpleService.java | 7 + .../springcontext/SpringContextComponent.java | 7 + .../sonar/java/model/PackageUtilsTest.java | 55 ----- .../BeanDefinitionGathererTest.java | 213 ++++++++++++++++++ .../ComponentScanPackageGathererTest.java | 37 +-- .../SpringContextGathererTest.java | 61 +++++ .../SpringContextModelGathererTest.java | 10 - .../springcontext/SpringContextModelTest.java | 15 +- .../sonar/java/utils/PackageUtilsTest.java | 95 ++++++++ 30 files changed, 788 insertions(+), 146 deletions(-) create mode 100644 java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java rename java-frontend/src/main/java/org/sonar/java/{model => utils}/PackageUtils.java (64%) create mode 100644 java-frontend/src/test/files/springcontext/AutowiredConstructorDependencies.java create mode 100644 java-frontend/src/test/files/springcontext/AutowiredDependencies.java create mode 100644 java-frontend/src/test/files/springcontext/BeanMethodWithDependencies.java create mode 100644 java-frontend/src/test/files/springcontext/ConfigurationWithBeanMethods.java create mode 100644 java-frontend/src/test/files/springcontext/ExplicitNameComponent.java create mode 100644 java-frontend/src/test/files/springcontext/PrimaryBean.java create mode 100644 java-frontend/src/test/files/springcontext/SimpleComponent.java create mode 100644 java-frontend/src/test/files/springcontext/SimpleConfiguration.java create mode 100644 java-frontend/src/test/files/springcontext/SimpleController.java create mode 100644 java-frontend/src/test/files/springcontext/SimpleRepository.java create mode 100644 java-frontend/src/test/files/springcontext/SimpleRestController.java create mode 100644 java-frontend/src/test/files/springcontext/SimpleService.java create mode 100644 java-frontend/src/test/files/springcontext/SpringContextComponent.java delete mode 100644 java-frontend/src/test/java/org/sonar/java/model/PackageUtilsTest.java create mode 100644 java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java create mode 100644 java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextGathererTest.java create mode 100644 java-frontend/src/test/java/org/sonar/java/utils/PackageUtilsTest.java diff --git a/java-checks/src/main/java/org/sonar/java/checks/MismatchPackageDirectoryCheck.java b/java-checks/src/main/java/org/sonar/java/checks/MismatchPackageDirectoryCheck.java index cef7752c75d..d3f93109398 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/MismatchPackageDirectoryCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/MismatchPackageDirectoryCheck.java @@ -20,7 +20,7 @@ import java.util.ArrayList; import java.util.List; import org.sonar.check.Rule; -import org.sonar.java.model.PackageUtils; +import org.sonar.java.utils.PackageUtils; import org.sonar.plugins.java.api.JavaFileScanner; import org.sonar.plugins.java.api.JavaFileScannerContext; import org.sonar.plugins.java.api.tree.BaseTreeVisitor; diff --git a/java-checks/src/main/java/org/sonar/java/checks/StaticMemberAccessCheck.java b/java-checks/src/main/java/org/sonar/java/checks/StaticMemberAccessCheck.java index e9cbe1bad81..9aa2af72291 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/StaticMemberAccessCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/StaticMemberAccessCheck.java @@ -19,7 +19,7 @@ import java.util.List; import org.sonar.check.Rule; import org.sonar.java.checks.helpers.QuickFixHelper; -import org.sonar.java.model.PackageUtils; +import org.sonar.java.utils.PackageUtils; import org.sonar.java.reporting.JavaQuickFix; import org.sonar.java.reporting.JavaTextEdit; import org.sonar.plugins.java.api.IssuableSubscriptionVisitor; diff --git a/java-checks/src/main/java/org/sonar/java/checks/UndocumentedApiCheck.java b/java-checks/src/main/java/org/sonar/java/checks/UndocumentedApiCheck.java index 1efb94e992c..8fcb9fe31cd 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/UndocumentedApiCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/UndocumentedApiCheck.java @@ -26,7 +26,7 @@ import org.sonar.check.RuleProperty; import org.sonar.java.ast.visitors.PublicApiChecker; import org.sonar.java.checks.helpers.Javadoc; -import org.sonar.java.model.PackageUtils; +import org.sonar.java.utils.PackageUtils; import org.sonar.plugins.java.api.JavaFileScanner; import org.sonar.plugins.java.api.JavaFileScannerContext; import org.sonar.plugins.java.api.semantic.SymbolMetadata; diff --git a/java-checks/src/main/java/org/sonar/java/checks/naming/BadPackageNameCheck.java b/java-checks/src/main/java/org/sonar/java/checks/naming/BadPackageNameCheck.java index 201e3f6ae67..5acb1aa2c34 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/naming/BadPackageNameCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/naming/BadPackageNameCheck.java @@ -19,7 +19,7 @@ import java.util.regex.Pattern; import org.sonar.check.Rule; import org.sonar.check.RuleProperty; -import org.sonar.java.model.PackageUtils; +import org.sonar.java.utils.PackageUtils; import org.sonar.plugins.java.api.JavaFileScanner; import org.sonar.plugins.java.api.JavaFileScannerContext; import org.sonar.plugins.java.api.tree.BaseTreeVisitor; diff --git a/java-checks/src/main/java/org/sonar/java/checks/spring/SpringBeansShouldBeAccessibleCheck.java b/java-checks/src/main/java/org/sonar/java/checks/spring/SpringBeansShouldBeAccessibleCheck.java index c900f61ab1e..fdaf4d7a914 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/spring/SpringBeansShouldBeAccessibleCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/spring/SpringBeansShouldBeAccessibleCheck.java @@ -32,6 +32,7 @@ import org.slf4j.LoggerFactory; import org.sonar.api.batch.fs.InputFile; import org.sonar.check.Rule; +import org.sonar.java.utils.PackageUtils; import org.sonar.java.utils.SpringUtils; import org.sonar.java.model.DefaultJavaFileScannerContext; import org.sonar.java.model.DefaultModuleScannerContext; @@ -115,7 +116,7 @@ public void visitNode(Tree tree) { return; } - String classPackageName = packageNameOf(classTree.symbol()); + String classPackageName = PackageUtils.packageNameOf(classTree.symbol()); SymbolMetadata classSymbolMetadata = classTree.symbol().metadata(); // try to apply "direct" annotation first @@ -199,7 +200,7 @@ private static List targetedPackages(String classPackageName, SymbolMeta if (!isClassBased && element instanceof String s) { packages.add(s); } else if (isClassBased && element instanceof Symbol s) { - packages.add(packageNameOf(s)); + packages.add(PackageUtils.packageNameOf(s)); } } } @@ -221,20 +222,12 @@ private void addToScannedPackages(SymbolMetadata.AnnotationValue annotationValue packagesScannedBySpringAtProjectLevel.add(oString); } if (o instanceof Symbol oSymbol) { - packagesScannedBySpringAtProjectLevel.add(packageNameOf(oSymbol)); + packagesScannedBySpringAtProjectLevel.add(PackageUtils.packageNameOf(oSymbol)); } } } } - private static String packageNameOf(Symbol symbol) { - Symbol owner = symbol.owner(); - while (!owner.isPackageSymbol()) { - owner = owner.owner(); - } - return owner.name(); - } - private static boolean hasAnnotation(SymbolMetadata classSymbolMetadata, String... annotationName) { return Arrays.stream(annotationName).anyMatch(classSymbolMetadata::isAnnotatedWith); } 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 new file mode 100644 index 00000000000..1d5602199e9 --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java @@ -0,0 +1,213 @@ +/* + * 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.beans.Introspector; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.sonar.api.batch.fs.InputFile; +import org.sonar.java.reporting.AnalyzerMessage; +import org.sonar.java.utils.PackageUtils; +import org.sonar.java.utils.SpringUtils; +import org.sonar.plugins.java.api.InputFileScannerContext; +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; +import org.sonar.plugins.java.api.tree.Tree; +import org.sonar.plugins.java.api.tree.VariableTree; + +/** + * 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. + * + *

Discovers beans from: + *

    + *
  • Classes annotated with stereotype annotations: {@code @Component}, {@code @Service}, + * {@code @Repository}, {@code @Controller}, {@code @RestController}, {@code @Configuration}
  • + *
  • {@code @Bean} methods inside {@code @Configuration} or {@code @Component} classes
  • + *
+ * + *

Also captures: + *

    + *
  • {@code @Primary} designation
  • + *
  • Dependencies via {@code @Autowired} fields, constructors, and setters for class-level beans
  • + *
  • Dependencies via method parameters for {@code @Bean} method beans
  • + *
+ */ +public class BeanDefinitionGatherer extends SpringContextModelGatherer { + + private static final String PRIMARY_ANNOTATION = "org.springframework.context.annotation.Primary"; + + private final List collectedBeans = new ArrayList<>(); + + private record BeanData( + String beanName, + String type, + String beanPackage, + InputFile inputFile, + AnalyzerMessage.TextSpan textSpan, + boolean isPrimary, + List dependingBeans) { + } + + @Override + public List nodesToVisit() { + return List.of(Tree.Kind.CLASS); + } + + @Override + public void visitNode(Tree tree) { + ClassTree classTree = (ClassTree) tree; + if (classTree.simpleName() == null) { + return; + } + + SymbolMetadata meta = classTree.symbol().metadata(); + String fqn = classTree.symbol().type().fullyQualifiedName(); + String pkg = PackageUtils.packageNameOf(classTree.symbol()); + + if (isSpringBeanDefinitionsClass(meta)) { + String beanName = extractBeanName(meta) + .orElseGet(() -> defaultBeanName(classTree.simpleName().name())); + List deps = collectAutowiredDependencies(classTree); + // Class-level bean (stereotype annotations) + collectedBeans.add(new BeanData( + beanName, fqn, pkg, + context.getInputFile(), + AnalyzerMessage.textSpanFor(classTree.simpleName()), + meta.isAnnotatedWith(PRIMARY_ANNOTATION), + deps)); + + // @Bean methods — only if class is a configuration/component class + for (MethodTree method : methodsOf(classTree)) { + if (method.symbol().metadata().isAnnotatedWith(SpringUtils.BEAN_ANNOTATION)) { + collectBeanMethod(method, pkg); + } + } + } + } + + @Override + public void gatherSpringContextData(ModuleScannerContext context, SpringContextModel springContextModel) { + for (BeanData data : collectedBeans) { + var location = new BeanLocation(data.inputFile(), data.textSpan()); + var holderBuilder = new BeanDefinitionHolder.Builder( + data.type(), context.getModuleKey(), data.beanPackage(), location) + .dependingBeans(data.dependingBeans()); + if (data.isPrimary()) { + holderBuilder.primary(); + } + springContextModel.getBeanDefinitionRegistry() + .addBeanDefinition(data.beanName(), holderBuilder.build()); + } + } + + @Override + public boolean scanWithoutParsing(InputFileScannerContext ctx) { + // Bean data is not cached yet; force parsing so beans are + // always collected even for unchanged files in incremental runs. + return false; + } + + private static boolean isSpringBeanDefinitionsClass(SymbolMetadata meta) { + return SpringUtils.STEREOTYPE_ANNOTATIONS.stream().anyMatch(meta::isAnnotatedWith); + } + + private static Optional extractBeanName(SymbolMetadata meta) { + for (String annotation : SpringUtils.STEREOTYPE_ANNOTATIONS) { + List attrs = meta.valuesForAnnotation(annotation); + if (attrs != null) { + Optional name = attrs.stream() + .filter(v -> "value".equals(v.name()) || "name".equals(v.name())) + .map(v -> (String) v.value()) + .filter(s -> !s.isBlank()) + .findFirst(); + if (name.isPresent()) { + return name; + } + } + } + return Optional.empty(); + } + + private static String defaultBeanName(String simpleName) { + return Introspector.decapitalize(simpleName); + } + + private void collectBeanMethod(MethodTree method, String pkg) { + SymbolMetadata beanMeta = method.symbol().metadata(); + List attrs = beanMeta.valuesForAnnotation(SpringUtils.BEAN_ANNOTATION); + String beanName = Optional.ofNullable(attrs) + .flatMap(list -> list.stream() + .filter(v -> "value".equals(v.name()) || "name".equals(v.name())) + .map(v -> { + Object val = v.value(); + if (val instanceof Object[] arr && arr.length > 0) { + return (String) arr[0]; + } + return val instanceof String s ? s : null; + }) + .filter(s -> s != null && !s.isBlank()) + .findFirst()) + .orElseGet(() -> method.simpleName().name()); + + String returnTypeFqn = method.returnType() != null + ? method.returnType().symbolType().fullyQualifiedName() + : ""; + + List paramDeps = method.parameters().stream() + .map(p -> p.symbol().type().fullyQualifiedName()) + .toList(); + + collectedBeans.add(new BeanData( + beanName, returnTypeFqn, pkg, + context.getInputFile(), + AnalyzerMessage.textSpanFor(method.simpleName()), + beanMeta.isAnnotatedWith(PRIMARY_ANNOTATION), + paramDeps)); + } + + private static List collectAutowiredDependencies(ClassTree classTree) { + List deps = new ArrayList<>(); + for (Tree member : classTree.members()) { + if (member.is(Tree.Kind.VARIABLE)) { + VariableTree field = (VariableTree) member; + if (field.symbol().metadata().isAnnotatedWith(SpringUtils.AUTOWIRED_ANNOTATION)) { + deps.add(field.symbol().type().fullyQualifiedName()); + } + } else if (member.is(Tree.Kind.CONSTRUCTOR, Tree.Kind.METHOD)) { + MethodTree method = (MethodTree) member; + if (method.symbol().metadata().isAnnotatedWith(SpringUtils.AUTOWIRED_ANNOTATION)) { + method.parameters().stream() + .map(p -> p.symbol().type().fullyQualifiedName()) + .forEach(deps::add); + } + } + } + return deps; + } + + private static List methodsOf(ClassTree classTree) { + return classTree.members().stream() + .filter(m -> m.is(Tree.Kind.METHOD)) + .map(MethodTree.class::cast) + .toList(); + } + +} 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 13d114e942c..3ad9ee586d9 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 @@ -28,6 +28,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.sonar.api.batch.fs.InputFile; +import org.sonar.java.utils.PackageUtils; import org.sonar.java.utils.SpringUtils; import org.sonar.plugins.java.api.InputFileScannerContext; import org.sonar.plugins.java.api.JavaFileScannerContext; @@ -127,7 +128,7 @@ private void collectFromSpringBootApplication(Symbol classSymbol, SymbolMetadata if (!metadata.isAnnotatedWith(SpringUtils.SPRING_BOOT_APP_ANNOTATION)) { return; } - var packages = targetedPackages(packageNameOf(classSymbol), metadata, packagesCollectedAtFileLevel.isEmpty()); + var packages = targetedPackages(PackageUtils.packageNameOf(classSymbol), metadata, packagesCollectedAtFileLevel.isEmpty()); collectedPackages.addAll(packages); packagesCollectedAtFileLevel.addAll(packages); } @@ -159,7 +160,7 @@ private void addAnnotationValueToCollectedPackages(SymbolMetadata.AnnotationValu collectedPackages.add(oString); packagesCollectedAtFileLevel.add(oString); } else if (o instanceof Symbol oSymbol) { - var pkg = packageNameOf(oSymbol); + var pkg = PackageUtils.packageNameOf(oSymbol); if (!pkg.isBlank()) { collectedPackages.add(pkg); packagesCollectedAtFileLevel.add(pkg); @@ -173,20 +174,12 @@ private static Optional resolvePackage(Object element, boolean isClassBa if (!isClassBased && element instanceof String s && !s.isBlank()) { return Optional.of(s); } else if (isClassBased && element instanceof Symbol s) { - var pkg = packageNameOf(s); + var pkg = PackageUtils.packageNameOf(s); return pkg.isBlank() ? Optional.empty() : Optional.of(pkg); } return Optional.empty(); } - private static String packageNameOf(Symbol symbol) { - Symbol owner = symbol.owner(); - while (!owner.isPackageSymbol()) { - owner = owner.owner(); - } - return owner.name(); - } - private static String cacheKey(InputFile inputFile) { return CACHE_KEY_PREFIX + inputFile.key(); } 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 e9d2db5c1d3..b3b178cdbf1 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 @@ -39,7 +39,8 @@ private SpringContextModelGatherers() { */ public static List getAllGatherers() { return List.of( - new ComponentScanPackageGatherer() + new ComponentScanPackageGatherer(), + new BeanDefinitionGatherer() ); } diff --git a/java-frontend/src/main/java/org/sonar/java/model/PackageUtils.java b/java-frontend/src/main/java/org/sonar/java/utils/PackageUtils.java similarity index 64% rename from java-frontend/src/main/java/org/sonar/java/model/PackageUtils.java rename to java-frontend/src/main/java/org/sonar/java/utils/PackageUtils.java index f3bdb42e141..23676da092d 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/PackageUtils.java +++ b/java-frontend/src/main/java/org/sonar/java/utils/PackageUtils.java @@ -14,25 +14,31 @@ * 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; +package org.sonar.java.utils; +import java.util.Deque; +import java.util.LinkedList; +import javax.annotation.Nullable; +import org.sonar.plugins.java.api.semantic.Symbol; import org.sonar.plugins.java.api.tree.ExpressionTree; import org.sonar.plugins.java.api.tree.IdentifierTree; import org.sonar.plugins.java.api.tree.MemberSelectExpressionTree; import org.sonar.plugins.java.api.tree.PackageDeclarationTree; import org.sonar.plugins.java.api.tree.Tree; -import javax.annotation.Nullable; -import java.util.Deque; -import java.util.LinkedList; +public final class PackageUtils { -public class PackageUtils { - - private PackageUtils(){ + private PackageUtils() { + // Utils class } + /** + * Returns the package name from a {@link PackageDeclarationTree}, using the given separator + * between package components (e.g. {@code "."} for Java-style, {@code "/"} for path-style). + * Returns an empty string when the declaration is {@code null} (default package). + */ public static String packageName(@Nullable PackageDeclarationTree packageDeclarationTree, String separator) { - if(packageDeclarationTree == null) { + if (packageDeclarationTree == null) { return ""; } Deque pieces = new LinkedList<>(); @@ -43,16 +49,23 @@ public static String packageName(@Nullable PackageDeclarationTree packageDeclara pieces.push(separator); expr = mse.expression(); } - if (expr.is(Tree.Kind.IDENTIFIER)) { - IdentifierTree idt = (IdentifierTree) expr; - pieces.push(idt.name()); - } - + pieces.push(((IdentifierTree) expr).name()); StringBuilder sb = new StringBuilder(); - for (String piece: pieces) { + for (String piece : pieces) { sb.append(piece); } return sb.toString(); } + /** + * Returns the package name of the given symbol by walking up the owner chain + * until a package symbol is found. + */ + public static String packageNameOf(Symbol symbol) { + Symbol owner = symbol.owner(); + while (!owner.isPackageSymbol()) { + owner = owner.owner(); + } + return owner.name(); + } } diff --git a/java-frontend/src/main/java/org/sonar/java/utils/SpringUtils.java b/java-frontend/src/main/java/org/sonar/java/utils/SpringUtils.java index 1adb19c993b..a18851b6333 100644 --- a/java-frontend/src/main/java/org/sonar/java/utils/SpringUtils.java +++ b/java-frontend/src/main/java/org/sonar/java/utils/SpringUtils.java @@ -43,6 +43,15 @@ public final class SpringUtils { public static final String REST_CONTROLLER_ANNOTATION = "org.springframework.web.bind.annotation.RestController"; public static final String SPRING_BOOT_TEST_ANNOTATION = "org.springframework.boot.test.context.SpringBootTest"; + public static final List STEREOTYPE_ANNOTATIONS = List.of( + COMPONENT_ANNOTATION, + SERVICE_ANNOTATION, + REPOSITORY_ANNOTATION, + CONTROLLER_ANNOTATION, + REST_CONTROLLER_ANNOTATION, + CONFIGURATION_ANNOTATION + ); + private SpringUtils() { // Utils class } diff --git a/java-frontend/src/test/files/springcontext/AutowiredConstructorDependencies.java b/java-frontend/src/test/files/springcontext/AutowiredConstructorDependencies.java new file mode 100644 index 00000000000..e7b2b180573 --- /dev/null +++ b/java-frontend/src/test/files/springcontext/AutowiredConstructorDependencies.java @@ -0,0 +1,19 @@ +package checks.spring.context; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +@Component +class AutowiredConstructorDependencies { + + private final ApplicationContext applicationContext; + private final Environment environment; + + @Autowired + AutowiredConstructorDependencies(ApplicationContext applicationContext, Environment environment) { + this.applicationContext = applicationContext; + this.environment = environment; + } +} diff --git a/java-frontend/src/test/files/springcontext/AutowiredDependencies.java b/java-frontend/src/test/files/springcontext/AutowiredDependencies.java new file mode 100644 index 00000000000..6241c160bc5 --- /dev/null +++ b/java-frontend/src/test/files/springcontext/AutowiredDependencies.java @@ -0,0 +1,16 @@ +package checks.spring.context; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +@Component +class AutowiredDependencies { + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private Environment environment; +} diff --git a/java-frontend/src/test/files/springcontext/BeanMethodWithDependencies.java b/java-frontend/src/test/files/springcontext/BeanMethodWithDependencies.java new file mode 100644 index 00000000000..e2747d1648e --- /dev/null +++ b/java-frontend/src/test/files/springcontext/BeanMethodWithDependencies.java @@ -0,0 +1,15 @@ +package checks.spring.context; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +@Configuration +class BeanMethodWithDependencies { + + @Bean + Object myBean(ApplicationContext serviceA, Environment serviceB) { + return new Object(); + } +} diff --git a/java-frontend/src/test/files/springcontext/ConfigurationWithBeanMethods.java b/java-frontend/src/test/files/springcontext/ConfigurationWithBeanMethods.java new file mode 100644 index 00000000000..a682108e51c --- /dev/null +++ b/java-frontend/src/test/files/springcontext/ConfigurationWithBeanMethods.java @@ -0,0 +1,29 @@ +package checks.spring.context; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +class ConfigurationWithBeanMethods { + + @Bean + ApplicationContext simpleServiceBean() { + return null; + } + + @Bean(name = "namedBean") + ApplicationContext namedBeanMethod() { + return null; + } + + @Bean(name = {"arrayNamedBean", "alias"}) + ApplicationContext arrayNamedBeanMethod() { + return null; + } + + @Bean(name = {}) + ApplicationContext emptyNameArrayMethod() { + return null; + } +} diff --git a/java-frontend/src/test/files/springcontext/ExplicitNameComponent.java b/java-frontend/src/test/files/springcontext/ExplicitNameComponent.java new file mode 100644 index 00000000000..f3433bc970f --- /dev/null +++ b/java-frontend/src/test/files/springcontext/ExplicitNameComponent.java @@ -0,0 +1,7 @@ +package checks.spring.context; + +import org.springframework.stereotype.Component; + +@Component("myBean") +class ExplicitNameComponent { +} diff --git a/java-frontend/src/test/files/springcontext/PrimaryBean.java b/java-frontend/src/test/files/springcontext/PrimaryBean.java new file mode 100644 index 00000000000..d46d46c6119 --- /dev/null +++ b/java-frontend/src/test/files/springcontext/PrimaryBean.java @@ -0,0 +1,9 @@ +package checks.spring.context; + +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Service; + +@Primary +@Service +class PrimaryBean { +} diff --git a/java-frontend/src/test/files/springcontext/SimpleComponent.java b/java-frontend/src/test/files/springcontext/SimpleComponent.java new file mode 100644 index 00000000000..758ad75967d --- /dev/null +++ b/java-frontend/src/test/files/springcontext/SimpleComponent.java @@ -0,0 +1,7 @@ +package checks.spring.context; + +import org.springframework.stereotype.Component; + +@Component +class SimpleComponent { +} diff --git a/java-frontend/src/test/files/springcontext/SimpleConfiguration.java b/java-frontend/src/test/files/springcontext/SimpleConfiguration.java new file mode 100644 index 00000000000..2f6304940ea --- /dev/null +++ b/java-frontend/src/test/files/springcontext/SimpleConfiguration.java @@ -0,0 +1,7 @@ +package checks.spring.context; + +import org.springframework.context.annotation.Configuration; + +@Configuration +class SimpleConfiguration { +} diff --git a/java-frontend/src/test/files/springcontext/SimpleController.java b/java-frontend/src/test/files/springcontext/SimpleController.java new file mode 100644 index 00000000000..d579e5885ba --- /dev/null +++ b/java-frontend/src/test/files/springcontext/SimpleController.java @@ -0,0 +1,7 @@ +package checks.spring.context; + +import org.springframework.stereotype.Controller; + +@Controller +class SimpleController { +} diff --git a/java-frontend/src/test/files/springcontext/SimpleRepository.java b/java-frontend/src/test/files/springcontext/SimpleRepository.java new file mode 100644 index 00000000000..844c5ce4145 --- /dev/null +++ b/java-frontend/src/test/files/springcontext/SimpleRepository.java @@ -0,0 +1,7 @@ +package checks.spring.context; + +import org.springframework.stereotype.Repository; + +@Repository +class SimpleRepository { +} diff --git a/java-frontend/src/test/files/springcontext/SimpleRestController.java b/java-frontend/src/test/files/springcontext/SimpleRestController.java new file mode 100644 index 00000000000..7758dfb3347 --- /dev/null +++ b/java-frontend/src/test/files/springcontext/SimpleRestController.java @@ -0,0 +1,7 @@ +package checks.spring.context; + +import org.springframework.web.bind.annotation.RestController; + +@RestController +class SimpleRestController { +} diff --git a/java-frontend/src/test/files/springcontext/SimpleService.java b/java-frontend/src/test/files/springcontext/SimpleService.java new file mode 100644 index 00000000000..ac362b7f353 --- /dev/null +++ b/java-frontend/src/test/files/springcontext/SimpleService.java @@ -0,0 +1,7 @@ +package checks.spring.context; + +import org.springframework.stereotype.Service; + +@Service +class SimpleService { +} diff --git a/java-frontend/src/test/files/springcontext/SpringContextComponent.java b/java-frontend/src/test/files/springcontext/SpringContextComponent.java new file mode 100644 index 00000000000..569ce308c5c --- /dev/null +++ b/java-frontend/src/test/files/springcontext/SpringContextComponent.java @@ -0,0 +1,7 @@ +package springcontext; + +import org.springframework.stereotype.Component; + +@Component +class SpringContextComponent { +} diff --git a/java-frontend/src/test/java/org/sonar/java/model/PackageUtilsTest.java b/java-frontend/src/test/java/org/sonar/java/model/PackageUtilsTest.java deleted file mode 100644 index cd7b96ff776..00000000000 --- a/java-frontend/src/test/java/org/sonar/java/model/PackageUtilsTest.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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; - -import org.junit.jupiter.api.Test; -import org.sonar.plugins.java.api.tree.CompilationUnitTree; - -import static org.assertj.core.api.Assertions.assertThat; - -class PackageUtilsTest { - - @Test - void no_package_empty_string() { - assertThat(packageName("class A{}")).isEmpty(); - } - - @Test - void identifier_package() { - assertThat(packageName("package foo; class A{}")).isEqualTo("foo"); - } - - @Test - void member_select_package() { - assertThat(packageName("package foo.bar.plop; class A{}")).isEqualTo("foo.bar.plop"); - } - - @Test - void different_separator() { - assertThat(packageName("package foo.bar.plop; class A{}", "/")).isEqualTo("foo/bar/plop"); - } - - private static String packageName(String code) { - return packageName(code, "."); - } - - private static String packageName(String code, String separator) { - CompilationUnitTree tree = JParserTestUtils.parse(code); - return PackageUtils.packageName(tree.packageDeclaration(), separator); - } - -} 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 new file mode 100644 index 00000000000..50ebcf2fb02 --- /dev/null +++ b/java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java @@ -0,0 +1,213 @@ +/* + * 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.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class BeanDefinitionGathererTest extends SpringContextGathererTest { + + @BeforeEach + void setUp() { + gatherer = new BeanDefinitionGatherer(); + model = new SpringContextModel(); + } + + // ---- Stereotype annotations ----------------------------------------------- + + @ParameterizedTest(name = "{0}") + @MethodSource("stereotypeAnnotationArguments") + void stereotype_annotation_registers_bean(String filePath, String expectedBeanName, String expectedType) { + scan(filePath); + + var beans = model.getBeanDefinitionRegistry().getByName(expectedBeanName); + assertThat(beans).hasSize(1); + assertThat(beans.get(0).getType()).isEqualTo(expectedType); + } + + static Stream stereotypeAnnotationArguments() { + return Stream.of( + Arguments.of("src/test/files/springcontext/SimpleComponent.java", "simpleComponent", "checks.spring.context.SimpleComponent"), + Arguments.of("src/test/files/springcontext/SimpleService.java", "simpleService", "checks.spring.context.SimpleService"), + Arguments.of("src/test/files/springcontext/SimpleRepository.java", "simpleRepository", "checks.spring.context.SimpleRepository"), + Arguments.of("src/test/files/springcontext/SimpleController.java", "simpleController", "checks.spring.context.SimpleController"), + Arguments.of("src/test/files/springcontext/SimpleRestController.java", "simpleRestController", "checks.spring.context.SimpleRestController"), + Arguments.of("src/test/files/springcontext/SimpleConfiguration.java", "simpleConfiguration", "checks.spring.context.SimpleConfiguration"), + Arguments.of("src/test/files/springcontext/ConfigurationWithBeanMethods.java", "simpleServiceBean", "org.springframework.context.ApplicationContext") + ); + } + + // ---- Explicit bean names --------------------------------------------------- + + @Test + void explicit_bean_name_from_annotation_value() { + scan("src/test/files/springcontext/ExplicitNameComponent.java"); + + var beans = model.getBeanDefinitionRegistry().getByName("myBean"); + assertThat(beans).hasSize(1); + assertThat(beans.get(0).getType()).isEqualTo("checks.spring.context.ExplicitNameComponent"); + // Default name should NOT be registered + assertThat(model.getBeanDefinitionRegistry().getByName("explicitNameComponent")).isEmpty(); + } + + // ---- @Bean methods -------------------------------------------------------- + + @Test + void bean_method_with_explicit_name() { + scan("src/test/files/springcontext/ConfigurationWithBeanMethods.java"); + + var beans = model.getBeanDefinitionRegistry().getByName("namedBean"); + assertThat(beans).hasSize(1); + assertThat(beans.get(0).getType()).isEqualTo("org.springframework.context.ApplicationContext"); + // Method name should NOT be registered + assertThat(model.getBeanDefinitionRegistry().getByName("namedBeanMethod")).isEmpty(); + } + + @Test + void bean_method_with_array_of_names_uses_first_name() { + scan("src/test/files/springcontext/ConfigurationWithBeanMethods.java"); + + var beans = model.getBeanDefinitionRegistry().getByName("arrayNamedBean"); + assertThat(beans).hasSize(1); + assertThat(beans.get(0).getType()).isEqualTo("org.springframework.context.ApplicationContext"); + } + + @Test + void bean_method_with_empty_name_array_falls_back_to_method_name() { + scan("src/test/files/springcontext/ConfigurationWithBeanMethods.java"); + + var beans = model.getBeanDefinitionRegistry().getByName("emptyNameArrayMethod"); + assertThat(beans).hasSize(1); + assertThat(beans.get(0).getType()).isEqualTo("org.springframework.context.ApplicationContext"); + } + + // ---- @Primary ------------------------------------------------------------- + + @Test + void primary_annotation_is_captured() { + scan("src/test/files/springcontext/PrimaryBean.java"); + + var beans = model.getBeanDefinitionRegistry().getByName("primaryBean"); + assertThat(beans).hasSize(1); + assertThat(beans.get(0).isPrimary()).isTrue(); + } + + @Test + void non_primary_bean_has_isPrimary_false() { + scan("src/test/files/springcontext/SimpleComponent.java"); + + var beans = model.getBeanDefinitionRegistry().getByName("simpleComponent"); + assertThat(beans).hasSize(1); + assertThat(beans.get(0).isPrimary()).isFalse(); + } + + // ---- Anonymous / no annotation -------------------------------------------- + + @Test + void anonymous_class_is_skipped() { + scan("src/test/files/springcontext/SpringBootAppWithAnonymousClass.java"); + + // Anonymous class (no simpleName) should be skipped — it would not be registered as a bean + // SpringBootApplication itself is not a stereotype bean + assertThat(model.getBeanDefinitionRegistry().getByName("")).isEmpty(); + } + + @Test + void no_spring_annotations_registers_nothing() { + scan("src/test/files/springcontext/NoScanAnnotations.java"); + + assertThat(model.getBeanDefinitionRegistry().getByName("noScanAnnotations")).isEmpty(); + } + + // ---- DependencyVersionAware ----------------------------------------------- + + @Test + void gatherer_skipped_when_spring_not_in_classpath() { + scan(List.of(), "src/test/files/springcontext/SimpleComponent.java"); + + assertThat(model.getBeanDefinitionRegistry().getByName("simpleComponent")).isEmpty(); + } + + // ---- Multiple files ------------------------------------------------------- + + @Test + void beans_from_multiple_files_are_merged() { + scan( + "src/test/files/springcontext/SimpleComponent.java", + "src/test/files/springcontext/SimpleService.java" + ); + + assertThat(model.getBeanDefinitionRegistry().getByName("simpleComponent")).hasSize(1); + assertThat(model.getBeanDefinitionRegistry().getByName("simpleService")).hasSize(1); + } + + // ---- @Autowired dependencies ---------------------------------------------- + + @ParameterizedTest(name = "{0}") + @MethodSource("dependencyCollectionArguments") + void dependencies_collected_as_depending_beans(String filePath, String expectedBeanName) { + scan(filePath); + + var beans = model.getBeanDefinitionRegistry().getByName(expectedBeanName); + assertThat(beans).hasSize(1); + assertThat(beans.get(0).getDependingBeans()) + .containsExactlyInAnyOrder( + "org.springframework.context.ApplicationContext", + "org.springframework.core.env.Environment" + ); + } + + static Stream dependencyCollectionArguments() { + return Stream.of( + Arguments.of("src/test/files/springcontext/AutowiredDependencies.java", "autowiredDependencies"), + Arguments.of("src/test/files/springcontext/AutowiredConstructorDependencies.java", "autowiredConstructorDependencies"), + Arguments.of("src/test/files/springcontext/BeanMethodWithDependencies.java", "myBean") + ); + } + + // ---- Bean location -------------------------------------------------------- + + @Test + void bean_location_is_captured() { + scan("src/test/files/springcontext/SimpleComponent.java"); + + var beans = model.getBeanDefinitionRegistry().getByName("simpleComponent"); + assertThat(beans).hasSize(1); + var location = beans.get(0).getLocation(); + assertThat(location).isNotNull(); + assertThat(location.inputFile()).isNotNull(); + assertThat(location.mainLocation()).isNotNull(); + } + + // ---- Bean package --------------------------------------------------------- + + @Test + void bean_package_is_captured() { + scan("src/test/files/springcontext/SimpleComponent.java"); + + var beans = model.getBeanDefinitionRegistry().getByName("simpleComponent"); + assertThat(beans).hasSize(1); + assertThat(beans.get(0).getBeanPackage()).isEqualTo("checks.spring.context"); + } +} \ No newline at end of file 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 a3ff19d2651..510a33bc3c2 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 @@ -25,13 +25,8 @@ import org.sonar.api.batch.fs.InputFile; import org.sonar.api.batch.sensor.cache.WriteCache; import org.sonar.api.batch.sensor.internal.SensorContextTester; -import org.sonar.java.SonarComponents; import org.sonar.java.TestUtils; -import org.sonar.java.model.JParserTestUtils; -import org.sonar.java.model.VisitorsBridge; -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.caching.CacheContext; import org.sonar.plugins.java.api.caching.JavaReadCache; @@ -46,13 +41,10 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -class ComponentScanPackageGathererTest { +class ComponentScanPackageGathererTest extends SpringContextGathererTest { private static final String MODULE_KEY = ""; - private SpringContextModel model; - private ComponentScanPackageGatherer gatherer; - @BeforeEach void setUp() { gatherer = new ComponentScanPackageGatherer(); @@ -251,33 +243,6 @@ void duplicate_cache_write_IllegalArgumentException_is_caught() { // ---- Helpers -------------------------------------------------------------- - private void scan(String... filePaths) { - scan(SensorContextTester.create(new File("")), filePaths); - } - - private void scan(List classpath, String... filePaths) { - scan(classpath, SensorContextTester.create(new File("")), filePaths); - } - - private void scan(SensorContextTester ctx, String... filePaths) { - scan(TestClasspathUtils.DEFAULT_MODULE.getClassPath(), ctx, filePaths); - } - - private 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); - var compilationUnit = JParserTestUtils.parse(file, classpath); - visitorsBridge.setCurrentFile(TestUtils.inputFile(file)); - visitorsBridge.visitFile(compilationUnit, false); - } - visitorsBridge.endOfAnalysis(); - } - private static CacheContext mockCacheContext(JavaReadCache readCache, JavaWriteCache writeCache) { CacheContext cacheContext = mock(CacheContext.class); when(cacheContext.isCacheEnabled()).thenReturn(true); 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 new file mode 100644 index 00000000000..304d0fe8616 --- /dev/null +++ b/java-frontend/src/test/java/org/sonar/java/model/springcontext/SpringContextGathererTest.java @@ -0,0 +1,61 @@ +/* + * 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.io.File; +import java.util.List; +import org.sonar.api.batch.sensor.internal.SensorContextTester; +import org.sonar.java.SonarComponents; +import org.sonar.java.TestUtils; +import org.sonar.java.model.JParserTestUtils; +import org.sonar.java.model.VisitorsBridge; +import org.sonar.java.test.classpath.TestClasspathUtils; +import org.sonar.plugins.java.api.JavaCheck; + +abstract class SpringContextGathererTest { + + protected SpringContextModelGatherer gatherer; + protected SpringContextModel model; + + protected void scan(String... filePaths) { + scan(TestClasspathUtils.DEFAULT_MODULE.getClassPath(), SensorContextTester.create(new File("")), filePaths); + } + + protected void scan(List classpath, String... filePaths) { + scan(classpath, SensorContextTester.create(new File("")), filePaths); + } + + protected void scan(SensorContextTester ctx, String... filePaths) { + scan(TestClasspathUtils.DEFAULT_MODULE.getClassPath(), ctx, 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); + var compilationUnit = JParserTestUtils.parse(file, classpath); + visitorsBridge.setCurrentFile(TestUtils.inputFile(file)); + visitorsBridge.visitFile(compilationUnit, false); + } + visitorsBridge.endOfAnalysis(); + } +} 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 7364ea2a97f..b25c82e81a3 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 @@ -62,16 +62,6 @@ void isCompatibleWithDependencies_false_when_no_spring_dependency_is_present() { assertThat(new SampleGatherer().isCompatibleWithDependencies(finderFor())).isFalse(); } - // ---- ComponentScanPackageGatherer ----------------------------------------- - - @Test - void componentScanPackageGatherer_collects_package_from_springBootApplication() { - var gatherer = new ComponentScanPackageGatherer(); - scanFile("src/test/files/springcontext/SpringBootApp.java", gatherer, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); - - assertThat(model.getProjectPackageScan().getPackagesForModule("")).containsExactly("springcontext"); - } - // ---- Helpers -------------------------------------------------------------- private void scanFile(String filePath, JavaCheck check, List classpath) { 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 bbcfbbea446..3c73c202bfa 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,7 +16,7 @@ */ package org.sonar.java.model.springcontext; -import java.util.Collections; +import java.util.List; import org.junit.jupiter.api.Test; import org.sonar.java.JavaFrontend; import org.sonar.java.Measurer; @@ -54,14 +54,21 @@ void scan_fills_project_package_scan_in_spring_context_model() { JavaFrontend frontend = new JavaFrontend(new JavaVersionImpl(), sonarComponents, mock(Measurer.class), new NoOpTelemetry(), mock(JavaResourceLocator.class), null); frontend.scan( - Collections.singletonList(TestUtils.inputFile("src/test/files/springcontext/SpringBootApp.java")), - Collections.emptyList(), - Collections.emptyList() + List.of( + TestUtils.inputFile("src/test/files/springcontext/SpringBootApp.java"), + TestUtils.inputFile("src/test/files/springcontext/SpringContextComponent.java") + ), + List.of(), + List.of() ); assertThat(springContextModel.getProjectPackageScan().getModules()).isNotEmpty(); assertThat(springContextModel.getProjectPackageScan().getPackagesForModule("a")) .containsExactly("springcontext"); + assertThat(springContextModel.getBeanDefinitionRegistry().getByName("springContextComponent")) + .hasSize(1) + .first() + .satisfies(bean -> assertThat(bean.getType()).isEqualTo("springcontext.SpringContextComponent")); } } diff --git a/java-frontend/src/test/java/org/sonar/java/utils/PackageUtilsTest.java b/java-frontend/src/test/java/org/sonar/java/utils/PackageUtilsTest.java new file mode 100644 index 00000000000..29a30d158ff --- /dev/null +++ b/java-frontend/src/test/java/org/sonar/java/utils/PackageUtilsTest.java @@ -0,0 +1,95 @@ +/* + * 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.utils; + +import org.junit.jupiter.api.Test; +import org.sonar.java.model.JParserTestUtils; +import org.sonar.java.test.classpath.TestClasspathUtils; +import org.sonar.plugins.java.api.semantic.Symbol; +import org.sonar.plugins.java.api.tree.ClassTree; +import org.sonar.plugins.java.api.tree.CompilationUnitTree; + +import static org.assertj.core.api.Assertions.assertThat; + +class PackageUtilsTest { + + // ---- packageName(PackageDeclarationTree, String) -------------------------- + + @Test + void packageName_no_package_returns_empty_string() { + assertThat(packageName("class A{}")).isEmpty(); + } + + @Test + void packageName_identifier_package() { + assertThat(packageName("package foo; class A{}")).isEqualTo("foo"); + } + + @Test + void packageName_member_select_package() { + assertThat(packageName("package foo.bar.plop; class A{}")).isEqualTo("foo.bar.plop"); + } + + @Test + void packageName_different_separator() { + assertThat(packageName("package foo.bar.plop; class A{}", "/")).isEqualTo("foo/bar/plop"); + } + + // ---- packageNameOf(Symbol) ------------------------------------------------ + + @Test + void packageNameOf_returns_package_of_class_symbol() { + var classpath = TestClasspathUtils.DEFAULT_MODULE.getClassPath(); + CompilationUnitTree tree = JParserTestUtils.parse("PackageUtilsTestClass", + "package com.example.pkg; class PackageUtilsTestClass {}", classpath); + Symbol classSymbol = ((ClassTree) tree.types().get(0)).symbol(); + assertThat(PackageUtils.packageNameOf(classSymbol)).isEqualTo("com.example.pkg"); + } + + @Test + void packageNameOf_returns_empty_string_for_default_package() { + var classpath = TestClasspathUtils.DEFAULT_MODULE.getClassPath(); + CompilationUnitTree tree = JParserTestUtils.parse("DefaultPkgClass", + "class DefaultPkgClass {}", classpath); + Symbol classSymbol = ((ClassTree) tree.types().get(0)).symbol(); + assertThat(PackageUtils.packageNameOf(classSymbol)).isEmpty(); + } + + @Test + void packageNameOf_returns_package_of_nested_class_symbol() { + var classpath = TestClasspathUtils.DEFAULT_MODULE.getClassPath(); + CompilationUnitTree tree = JParserTestUtils.parse("Outer", + "package com.example.pkg; class Outer { static class Inner {} }", classpath); + ClassTree outerClass = (ClassTree) tree.types().get(0); + ClassTree innerClass = outerClass.members().stream() + .filter(ClassTree.class::isInstance) + .map(ClassTree.class::cast) + .findFirst().orElseThrow(); + assertThat(PackageUtils.packageNameOf(innerClass.symbol())).isEqualTo("com.example.pkg"); + } + + // ---- Helpers -------------------------------------------------------------- + + private static String packageName(String code) { + return packageName(code, "."); + } + + private static String packageName(String code, String separator) { + CompilationUnitTree tree = JParserTestUtils.parse(code); + return PackageUtils.packageName(tree.packageDeclaration(), separator); + } +} From 3638402575f9b0de7101cee93e17e10fc4b20f0a Mon Sep 17 00:00:00 2001 From: asya-vorobeva Date: Thu, 11 Jun 2026 14:22:27 +0200 Subject: [PATCH 2/2] Fixes after review --- .../checks/ConfigurationBeanNamesCheck.java | 10 +--------- .../springcontext/BeanDefinitionGatherer.java | 19 +++---------------- .../org/sonar/java/utils/SpringUtils.java | 8 ++++++++ 3 files changed, 12 insertions(+), 25 deletions(-) diff --git a/java-checks/src/main/java/org/sonar/java/checks/ConfigurationBeanNamesCheck.java b/java-checks/src/main/java/org/sonar/java/checks/ConfigurationBeanNamesCheck.java index 4680a599a58..1290116c68c 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/ConfigurationBeanNamesCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/ConfigurationBeanNamesCheck.java @@ -40,7 +40,7 @@ public void visitNode(Tree tree) { return; } - var beanMethods = getBeanMethods(classTree); + var beanMethods = SpringUtils.getBeanMethods(classTree); var foundNames = new HashSet(); for (MethodTree beanMethod : beanMethods) { if (!foundNames.add(beanMethod.simpleName().name())) { @@ -53,12 +53,4 @@ private static boolean isConfigurationClass(ClassTree classTree) { return classTree.symbol().metadata().isAnnotatedWith(SpringUtils.CONFIGURATION_ANNOTATION); } - private static List getBeanMethods(ClassTree classTree) { - return classTree.members().stream() - .filter(member -> member.is(Tree.Kind.METHOD)) - .map(MethodTree.class::cast) - .filter(method -> method.symbol().metadata().isAnnotatedWith(SpringUtils.BEAN_ANNOTATION)) - .toList(); - } - } 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 1d5602199e9..a4b4e3c3ff1 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 @@ -82,7 +82,7 @@ public void visitNode(Tree tree) { String fqn = classTree.symbol().type().fullyQualifiedName(); String pkg = PackageUtils.packageNameOf(classTree.symbol()); - if (isSpringBeanDefinitionsClass(meta)) { + if (SpringUtils.STEREOTYPE_ANNOTATIONS.stream().anyMatch(meta::isAnnotatedWith)) { String beanName = extractBeanName(meta) .orElseGet(() -> defaultBeanName(classTree.simpleName().name())); List deps = collectAutowiredDependencies(classTree); @@ -95,10 +95,8 @@ public void visitNode(Tree tree) { deps)); // @Bean methods — only if class is a configuration/component class - for (MethodTree method : methodsOf(classTree)) { - if (method.symbol().metadata().isAnnotatedWith(SpringUtils.BEAN_ANNOTATION)) { - collectBeanMethod(method, pkg); - } + for (MethodTree method : SpringUtils.getBeanMethods(classTree)) { + collectBeanMethod(method, pkg); } } } @@ -125,10 +123,6 @@ public boolean scanWithoutParsing(InputFileScannerContext ctx) { return false; } - private static boolean isSpringBeanDefinitionsClass(SymbolMetadata meta) { - return SpringUtils.STEREOTYPE_ANNOTATIONS.stream().anyMatch(meta::isAnnotatedWith); - } - private static Optional extractBeanName(SymbolMetadata meta) { for (String annotation : SpringUtils.STEREOTYPE_ANNOTATIONS) { List attrs = meta.valuesForAnnotation(annotation); @@ -203,11 +197,4 @@ private static List collectAutowiredDependencies(ClassTree classTree) { return deps; } - private static List methodsOf(ClassTree classTree) { - return classTree.members().stream() - .filter(m -> m.is(Tree.Kind.METHOD)) - .map(MethodTree.class::cast) - .toList(); - } - } diff --git a/java-frontend/src/main/java/org/sonar/java/utils/SpringUtils.java b/java-frontend/src/main/java/org/sonar/java/utils/SpringUtils.java index a18851b6333..f1dc9d323be 100644 --- a/java-frontend/src/main/java/org/sonar/java/utils/SpringUtils.java +++ b/java-frontend/src/main/java/org/sonar/java/utils/SpringUtils.java @@ -90,4 +90,12 @@ public static boolean isSpringBootUnitTest(MethodTree methodTree) { return UnitTestUtils.isUnitTest(methodTree) && SpringUtils.isSpringBootTestClass(parentClass.symbol()); } + public static List getBeanMethods(ClassTree classTree) { + return classTree.members().stream() + .filter(member -> member.is(Tree.Kind.METHOD)) + .map(MethodTree.class::cast) + .filter(method -> method.symbol().metadata().isAnnotatedWith(BEAN_ANNOTATION)) + .toList(); + } + }