From 354757dd84d93490b30d1775422bf1c99a5f3fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Mon, 24 Aug 2026 09:49:52 +0200 Subject: [PATCH 01/13] Add test for name resolution with qualifier when there are multiple beans of the same type --- .../springcontext/QualifierOrderService.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 java-frontend/src/test/files/springcontext/QualifierOrderService.java diff --git a/java-frontend/src/test/files/springcontext/QualifierOrderService.java b/java-frontend/src/test/files/springcontext/QualifierOrderService.java new file mode 100644 index 00000000000..8b9572c0065 --- /dev/null +++ b/java-frontend/src/test/files/springcontext/QualifierOrderService.java @@ -0,0 +1,17 @@ +package checks.spring.context; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Service; + +@Service +class QualifierOrderService { + + private final ApplicationContext applicationContext; + + @Autowired + QualifierOrderService(@Qualifier("namedBean") ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } +} From e3021259f1e5b40675f14ae94d5bc4fe23b76c1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Fri, 21 Aug 2026 13:51:33 +0200 Subject: [PATCH 02/13] Implement visitor for grouping beans of the same type --- .../SpringContextModelGatherers.java | 3 +- .../springcontext/TypeToBeanNamesIndex.java | 2 +- .../TypeToBeanNamesIndexGatherer.java | 100 ++++++++++++++++++ .../org/sonar/java/utils/SpringUtils.java | 48 ++++++++- 4 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java 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 b3b178cdbf1..257f9e200ac 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 @@ -40,7 +40,8 @@ private SpringContextModelGatherers() { public static List getAllGatherers() { return List.of( new ComponentScanPackageGatherer(), - new BeanDefinitionGatherer() + new BeanDefinitionGatherer(), + new TypeToBeanNamesIndexGatherer() ); } diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndex.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndex.java index b21e6e561af..ac054f4bfe6 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndex.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndex.java @@ -53,4 +53,4 @@ public void addBeanForType(String beanType, String beanName) { public Set getNamesForType(String beanType) { return Collections.unmodifiableSet(beanNamesByType.getOrDefault(beanType, Set.of())); } -} \ No newline at end of file +} diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java new file mode 100644 index 00000000000..97e502b6d87 --- /dev/null +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java @@ -0,0 +1,100 @@ +/* + * 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.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.sonar.java.utils.SpringUtils; +import org.sonar.plugins.java.api.ModuleScannerContext; +import org.sonar.plugins.java.api.semantic.Symbol; +import org.sonar.plugins.java.api.semantic.Type; +import org.sonar.plugins.java.api.tree.ClassTree; +import org.sonar.plugins.java.api.tree.MethodTree; +import org.sonar.plugins.java.api.tree.Tree; + +/** + * Populates {@link TypeToBeanNamesIndex} by mapping every type in a bean's hierarchy + * (concrete class, superclasses, interfaces) to the bean's name. + */ +public class TypeToBeanNamesIndexGatherer extends SpringContextModelGatherer { + + private record BeanTypeEntry(String beanName, Set typeHierarchy) {} + + private final List collectedEntries = new ArrayList<>(); + + @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; + } + + var meta = classTree.symbol().metadata(); + if (SpringUtils.STEREOTYPE_ANNOTATIONS.stream().anyMatch(meta::isAnnotatedWith)) { + String beanName = SpringUtils.resolveStereotypeBeanName(meta, classTree.simpleName().name()); + collectedEntries.add(new BeanTypeEntry(beanName, collectTypeHierarchy(classTree.symbol()))); + + for (MethodTree method : SpringUtils.getBeanMethods(classTree)) { + if (method.returnType() == null) { + continue; + } + String methodBeanName = SpringUtils.resolveBeanMethodName(method); + Symbol.TypeSymbol returnTypeSymbol = method.returnType().symbolType().symbol(); + collectedEntries.add(new BeanTypeEntry(methodBeanName, collectTypeHierarchy(returnTypeSymbol))); + } + } + } + + @Override + public void gatherSpringContextData(ModuleScannerContext context, SpringContextModel springContextModel) { + TypeToBeanNamesIndex index = springContextModel.getTypeToBeanNamesIndex(); + for (BeanTypeEntry entry : collectedEntries) { + for (String typeFqn : entry.typeHierarchy()) { + index.addBeanForType(typeFqn, entry.beanName()); + } + } + } + + private static Set collectTypeHierarchy(Symbol.TypeSymbol symbol) { + Set visited = new LinkedHashSet<>(); + walkTypeHierarchy(symbol, visited); + return visited; + } + + private static void walkTypeHierarchy(Symbol.TypeSymbol symbol, Set visited) { + String fqn = symbol.type().fullyQualifiedName(); + if ("java.lang.Object".equals(fqn) || symbol.type().isUnknown() || !visited.add(fqn)) { + return; + } + Type superClass = symbol.superClass(); + if (superClass != null && !superClass.isUnknown()) { + walkTypeHierarchy(superClass.symbol(), visited); + } + for (Type iface : symbol.interfaces()) { + if (!iface.isUnknown()) { + walkTypeHierarchy(iface.symbol(), visited); + } + } + } +} 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 9673af815dd..0c6e8b31489 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 @@ -16,7 +16,9 @@ */ package org.sonar.java.utils; +import java.beans.Introspector; import java.util.List; +import java.util.Optional; import org.sonar.java.model.ExpressionUtils; import org.sonar.plugins.java.api.semantic.Symbol; @@ -53,6 +55,8 @@ public final class SpringUtils { CONFIGURATION_ANNOTATION ); + public static final String VALUE_ATTRIBUTE = "value"; + private SpringUtils() { // Utils class } @@ -64,7 +68,7 @@ public static boolean isScopeSingleton(SymbolMetadata clazzMeta) { return true; } for (SymbolMetadata.AnnotationValue annotationValue : values) { - if ("value".equals(annotationValue.name()) || "scopeName".equals(annotationValue.name())) { + if (VALUE_ATTRIBUTE.equals(annotationValue.name()) || "scopeName".equals(annotationValue.name())) { Object value = annotationValue.value(); if (value instanceof String stringValue && !"singleton".equals(stringValue)) { return false; @@ -91,6 +95,48 @@ public static boolean isSpringBootUnitTest(MethodTree methodTree) { return UnitTestUtils.isUnitTest(methodTree) && SpringUtils.isSpringBootTestClass(parentClass.symbol()); } + /** + * Resolves the Spring bean name for a stereotype-annotated class. + * Returns the explicit name from the annotation if present, otherwise the decapitalized simple class name. + */ + public static String resolveStereotypeBeanName(SymbolMetadata meta, String simpleName) { + for (String annotation : STEREOTYPE_ANNOTATIONS) { + List attrs = meta.valuesForAnnotation(annotation); + if (attrs != null) { + Optional name = attrs.stream() + .filter(v -> VALUE_ATTRIBUTE.equals(v.name()) || "name".equals(v.name())) + .map(v -> (String) v.value()) + .filter(s -> !s.isBlank()) + .findFirst(); + if (name.isPresent()) { + return name.get(); + } + } + } + return Introspector.decapitalize(simpleName); + } + + /** + * Resolves the Spring bean name for a {@code @Bean} factory method. + * Returns the explicit name/value from the annotation if present, otherwise the method name. + */ + public static String resolveBeanMethodName(MethodTree method) { + List attrs = method.symbol().metadata().valuesForAnnotation(BEAN_ANNOTATION); + return Optional.ofNullable(attrs) + .flatMap(list -> list.stream() + .filter(v -> VALUE_ATTRIBUTE.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()); + } + public static List getBeanMethods(ClassTree classTree) { return classTree.members().stream() .filter(member -> member.is(Tree.Kind.METHOD)) From 84e060a2133c7e8c051b94f0c657a33991a2203a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Fri, 21 Aug 2026 14:05:02 +0200 Subject: [PATCH 03/13] Write tests for new visitor --- .../ComponentImplementingInterface.java | 14 ++ .../TypeToBeanNamesIndexGathererTest.java | 132 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 java-frontend/src/test/files/springcontext/ComponentImplementingInterface.java create mode 100644 java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java diff --git a/java-frontend/src/test/files/springcontext/ComponentImplementingInterface.java b/java-frontend/src/test/files/springcontext/ComponentImplementingInterface.java new file mode 100644 index 00000000000..82670f3fb83 --- /dev/null +++ b/java-frontend/src/test/files/springcontext/ComponentImplementingInterface.java @@ -0,0 +1,14 @@ +package checks.spring.context; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +@Component +class ComponentImplementingInterface implements ApplicationContextAware { + + @Override + public void setApplicationContext(ApplicationContext ctx) { + // not needed for test + } +} diff --git a/java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java b/java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java new file mode 100644 index 00000000000..c92639194ea --- /dev/null +++ b/java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java @@ -0,0 +1,132 @@ +/* + * 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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class TypeToBeanNamesIndexGathererTest extends SpringContextGathererTest { + + @BeforeEach + void setUp() { + gatherer = new TypeToBeanNamesIndexGatherer(); + model = new SpringContextModel(); + } + + // ---- Stereotype beans ------------------------------------------------------- + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "src/test/files/springcontext/SimpleComponent.java", + "src/test/files/springcontext/SimpleService.java", + "src/test/files/springcontext/SimpleRepository.java", + "src/test/files/springcontext/SimpleController.java", + "src/test/files/springcontext/SimpleRestController.java", + "src/test/files/springcontext/SimpleConfiguration.java" + }) + void stereotype_bean_is_registered_under_its_own_type(String filePath) { + scan(filePath); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("checks.spring.context." + beanClassNameFrom(filePath))) + .isNotEmpty(); + } + + @Test + void bean_is_registered_under_implemented_interface() { + scan("src/test/files/springcontext/ComponentImplementingInterface.java"); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("checks.spring.context.ComponentImplementingInterface")) + .containsOnly("componentImplementingInterface"); + assertThat(index.getNamesForType("org.springframework.context.ApplicationContextAware")) + .containsOnly("componentImplementingInterface"); + } + + @Test + void explicit_bean_name_is_used_in_index() { + scan("src/test/files/springcontext/ExplicitNameComponent.java"); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("checks.spring.context.ExplicitNameComponent")) + .containsOnly("myBean"); + } + + // ---- @Bean methods ---------------------------------------------------------- + + @Test + void bean_method_return_type_is_registered() { + scan("src/test/files/springcontext/ConfigurationWithBeanMethods.java"); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("org.springframework.context.ApplicationContext")) + .contains("simpleServiceBean", "namedBean", "arrayNamedBean", "emptyNameArrayMethod"); + } + + // ---- Multiple beans --------------------------------------------------------- + + @Test + void multiple_beans_of_same_type_all_registered() { + scan( + "src/test/files/springcontext/SimpleComponent.java", + "src/test/files/springcontext/SimpleService.java" + ); + + var index = model.getTypeToBeanNamesIndex(); + // Each bean appears only under its own concrete type + assertThat(index.getNamesForType("checks.spring.context.SimpleComponent")) + .containsOnly("simpleComponent"); + assertThat(index.getNamesForType("checks.spring.context.SimpleService")) + .containsOnly("simpleService"); + } + + // ---- No annotation ---------------------------------------------------------- + + @Test + void non_spring_class_registers_nothing() { + scan("src/test/files/springcontext/NoScanAnnotations.java"); + + assertThat(model.getTypeToBeanNamesIndex().getNamesForType("checks.spring.context.NoScanAnnotations")) + .isEmpty(); + } + + @Test + void gatherer_skipped_when_spring_not_in_classpath() { + scan(List.of(), "src/test/files/springcontext/SimpleComponent.java"); + + assertThat(model.getTypeToBeanNamesIndex().getNamesForType("checks.spring.context.SimpleComponent")) + .isEmpty(); + } + + @Test + void anonymous_class_is_skipped() { + scan("src/test/files/springcontext/SpringBootAppWithAnonymousClass.java"); + + assertThat(model.getTypeToBeanNamesIndex().getNamesForType("")).isEmpty(); + } + + // ---- Helpers ---------------------------------------------------------------- + + private static String beanClassNameFrom(String filePath) { + return filePath.substring(filePath.lastIndexOf('/') + 1, filePath.lastIndexOf('.')); + } +} From 841f2a3f612e1ee7af944668ca87a1fe20b80180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Fri, 21 Aug 2026 14:39:28 +0200 Subject: [PATCH 04/13] Address review comments --- .../TypeToBeanNamesIndexGatherer.java | 7 +-- .../org/sonar/java/utils/SpringUtils.java | 46 ++++++++++++------- .../TypeToBeanNamesIndexGathererTest.java | 10 ++++ 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java index 97e502b6d87..3f080d57cb7 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java @@ -59,9 +59,10 @@ public void visitNode(Tree tree) { if (method.returnType() == null) { continue; } - String methodBeanName = SpringUtils.resolveBeanMethodName(method); - Symbol.TypeSymbol returnTypeSymbol = method.returnType().symbolType().symbol(); - collectedEntries.add(new BeanTypeEntry(methodBeanName, collectTypeHierarchy(returnTypeSymbol))); + Set typeHierarchy = collectTypeHierarchy(method.returnType().symbolType().symbol()); + for (String methodBeanName : SpringUtils.resolveBeanMethodNames(method)) { + collectedEntries.add(new BeanTypeEntry(methodBeanName, typeHierarchy)); + } } } } 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 0c6e8b31489..4d3befa676b 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 @@ -17,8 +17,10 @@ package org.sonar.java.utils; import java.beans.Introspector; +import java.util.Arrays; import java.util.List; import java.util.Optional; +import java.util.stream.Stream; import org.sonar.java.model.ExpressionUtils; import org.sonar.plugins.java.api.semantic.Symbol; @@ -106,7 +108,7 @@ public static String resolveStereotypeBeanName(SymbolMetadata meta, String simpl Optional name = attrs.stream() .filter(v -> VALUE_ATTRIBUTE.equals(v.name()) || "name".equals(v.name())) .map(v -> (String) v.value()) - .filter(s -> !s.isBlank()) + .filter(s -> s != null && !s.isBlank()) .findFirst(); if (name.isPresent()) { return name.get(); @@ -117,24 +119,34 @@ public static String resolveStereotypeBeanName(SymbolMetadata meta, String simpl } /** - * Resolves the Spring bean name for a {@code @Bean} factory method. - * Returns the explicit name/value from the annotation if present, otherwise the method name. + * Resolves all Spring bean names for a {@code @Bean} factory method, including aliases. + * Returns the explicit names from the annotation if present, otherwise a singleton list of the method name. */ - public static String resolveBeanMethodName(MethodTree method) { + public static List resolveBeanMethodNames(MethodTree method) { List attrs = method.symbol().metadata().valuesForAnnotation(BEAN_ANNOTATION); - return Optional.ofNullable(attrs) - .flatMap(list -> list.stream() - .filter(v -> VALUE_ATTRIBUTE.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()); + if (attrs == null) { + return List.of(method.simpleName().name()); + } + List names = attrs.stream() + .filter(v -> VALUE_ATTRIBUTE.equals(v.name()) || "name".equals(v.name())) + .flatMap(v -> { + Object val = v.value(); + if (val instanceof Object[] arr) { + return Arrays.stream(arr).filter(String.class::isInstance).map(String.class::cast); + } + return val instanceof String s ? Stream.of(s) : Stream.empty(); + }) + .filter(s -> !s.isBlank()) + .toList(); + return names.isEmpty() ? List.of(method.simpleName().name()) : names; + } + + /** + * Resolves the primary Spring bean name for a {@code @Bean} factory method. + * Returns the first explicit name from the annotation if present, otherwise the method name. + */ + public static String resolveBeanMethodName(MethodTree method) { + return resolveBeanMethodNames(method).get(0); } public static List getBeanMethods(ClassTree classTree) { diff --git a/java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java b/java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java index c92639194ea..bcf5df67043 100644 --- a/java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java +++ b/java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java @@ -82,6 +82,16 @@ void bean_method_return_type_is_registered() { .contains("simpleServiceBean", "namedBean", "arrayNamedBean", "emptyNameArrayMethod"); } + @Test + void bean_method_aliases_are_all_registered() { + scan("src/test/files/springcontext/ConfigurationWithBeanMethods.java"); + + // @Bean(name = {"arrayNamedBean", "alias"}) — both names must appear in the index + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("org.springframework.context.ApplicationContext")) + .contains("arrayNamedBean", "alias"); + } + // ---- Multiple beans --------------------------------------------------------- @Test From 84b2e9ac0dc3181d8f3b1b8e1f709ce556e4ae88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Fri, 21 Aug 2026 15:29:15 +0200 Subject: [PATCH 05/13] Improve test coverage --- .../TypeToBeanNamesIndexGatherer.java | 3 - .../org/sonar/java/utils/SpringUtils.java | 4 +- .../org/sonar/java/utils/SpringUtilsTest.java | 220 ++++++++++++++++++ 3 files changed, 222 insertions(+), 5 deletions(-) diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java index 3f080d57cb7..268f06e5770 100644 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java +++ b/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java @@ -56,9 +56,6 @@ public void visitNode(Tree tree) { collectedEntries.add(new BeanTypeEntry(beanName, collectTypeHierarchy(classTree.symbol()))); for (MethodTree method : SpringUtils.getBeanMethods(classTree)) { - if (method.returnType() == null) { - continue; - } Set typeHierarchy = collectTypeHierarchy(method.returnType().symbolType().symbol()); for (String methodBeanName : SpringUtils.resolveBeanMethodNames(method)) { collectedEntries.add(new BeanTypeEntry(methodBeanName, typeHierarchy)); 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 4d3befa676b..2affb8e8f3e 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 @@ -106,7 +106,7 @@ public static String resolveStereotypeBeanName(SymbolMetadata meta, String simpl List attrs = meta.valuesForAnnotation(annotation); if (attrs != null) { Optional name = attrs.stream() - .filter(v -> VALUE_ATTRIBUTE.equals(v.name()) || "name".equals(v.name())) + .filter(v -> VALUE_ATTRIBUTE.equals(v.name())) .map(v -> (String) v.value()) .filter(s -> s != null && !s.isBlank()) .findFirst(); @@ -134,7 +134,7 @@ public static List resolveBeanMethodNames(MethodTree method) { if (val instanceof Object[] arr) { return Arrays.stream(arr).filter(String.class::isInstance).map(String.class::cast); } - return val instanceof String s ? Stream.of(s) : Stream.empty(); + return Stream.empty(); }) .filter(s -> !s.isBlank()) .toList(); diff --git a/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java b/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java index e326a72c7c7..8416c28101e 100644 --- a/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java +++ b/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java @@ -19,7 +19,9 @@ import org.junit.jupiter.api.Test; import org.sonar.java.model.JParserTestUtils; import org.sonar.java.model.declaration.ClassTreeImpl; +import org.sonar.java.model.declaration.MethodTreeImpl; import org.sonar.java.model.declaration.VariableTreeImpl; +import org.sonar.java.test.classpath.TestClasspathUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -48,4 +50,222 @@ class A { assertThat(SpringUtils.isAutowired(hoo.symbol())).isFalse(); } + // ---- isScopeSingleton ------------------------------------------------------- + + @Test + void is_scope_singleton_no_annotation_returns_true() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.stereotype.Component + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isScopeSingleton(clazz.symbol().metadata())).isTrue(); + } + + @Test + void is_scope_singleton_with_singleton_scope_returns_true() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.context.annotation.Scope("singleton") + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isScopeSingleton(clazz.symbol().metadata())).isTrue(); + } + + @Test + void is_scope_singleton_with_prototype_scope_returns_false() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.context.annotation.Scope("prototype") + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isScopeSingleton(clazz.symbol().metadata())).isFalse(); + } + + @Test + void is_scope_singleton_with_scope_name_attribute_and_prototype_returns_false() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.context.annotation.Scope(scopeName = "prototype") + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isScopeSingleton(clazz.symbol().metadata())).isFalse(); + } + + // ---- isSpringBootTestClass -------------------------------------------------- + + @Test + void is_spring_boot_test_class_with_annotation_returns_true() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.boot.test.context.SpringBootTest + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isSpringBootTestClass(clazz.symbol())).isTrue(); + } + + @Test + void is_spring_boot_test_class_without_annotation_returns_false() { + var cu = JParserTestUtils.parse("class A {}"); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.isSpringBootTestClass(clazz.symbol())).isFalse(); + } + + // ---- isSpringBootUnitTest --------------------------------------------------- + + @Test + void is_spring_boot_unit_test_method_in_interface_returns_false() { + // getParentOfType(method, CLASS) returns null for methods inside interfaces (kind is INTERFACE, not CLASS) + var cu = JParserTestUtils.parse("interface A { default void m() {} }"); + var iface = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) iface.members().get(0); + assertThat(SpringUtils.isSpringBootUnitTest(method)).isFalse(); + } + + @Test + void is_spring_boot_unit_test_in_spring_boot_test_class_returns_true() { + var cu = JParserTestUtils.parse("A", """ + import org.junit.jupiter.api.Test; + @org.springframework.boot.test.context.SpringBootTest + class A { + @Test + void myTest() {} + } + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.isSpringBootUnitTest(method)).isTrue(); + } + + @Test + void is_spring_boot_unit_test_in_non_spring_class_returns_false() { + var cu = JParserTestUtils.parse("A", """ + import org.junit.jupiter.api.Test; + class A { + @Test + void myTest() {} + } + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.isSpringBootUnitTest(method)).isFalse(); + } + + // ---- resolveStereotypeBeanName ---------------------------------------------- + + @Test + void resolve_stereotype_bean_name_uses_name_attribute() { + // Covers the "name".equals(v.name()) branch in the filter + var cu = JParserTestUtils.parse("A", """ + @org.springframework.stereotype.Service(value = "myService") + class A {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.resolveStereotypeBeanName(clazz.symbol().metadata(), "A")).isEqualTo("myService"); + } + + @Test + void resolve_stereotype_bean_name_falls_back_to_decapitalized_name() { + var cu = JParserTestUtils.parse("A", """ + @org.springframework.stereotype.Component + class MyServiceImpl {} + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + assertThat(SpringUtils.resolveStereotypeBeanName(clazz.symbol().metadata(), "MyServiceImpl")).isEqualTo("myServiceImpl"); + } + + // ---- resolveBeanMethodNames ------------------------------------------------- + + @Test + void resolve_bean_method_names_no_annotation_returns_method_name() { + var cu = JParserTestUtils.parse("class A { Object myMethod() { return null; } }"); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.resolveBeanMethodNames(method)).containsOnly("myMethod"); + } + + @Test + void resolve_bean_method_names_single_string_name() { + var cu = JParserTestUtils.parse("A", """ + import org.springframework.context.annotation.Bean; + class A { + @Bean(name = "myBean") + Object myMethod() { return null; } + } + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.resolveBeanMethodNames(method)).containsOnly("myBean"); + } + + @Test + void resolve_bean_method_names_array_includes_all_aliases() { + var cu = JParserTestUtils.parse("A", """ + import org.springframework.context.annotation.Bean; + class A { + @Bean(name = {"primary", "alias"}) + Object myMethod() { return null; } + } + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.resolveBeanMethodNames(method)).containsExactlyInAnyOrder("primary", "alias"); + } + + @Test + void resolve_bean_method_names_empty_array_falls_back_to_method_name() { + var cu = JParserTestUtils.parse("A", """ + import org.springframework.context.annotation.Bean; + class A { + @Bean(name = {}) + Object myMethod() { return null; } + } + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.resolveBeanMethodNames(method)).containsOnly("myMethod"); + } + + @Test + void resolve_bean_method_names_non_name_attribute_is_ignored() { + // @Bean(initMethod="...") — the "initMethod" attribute is not "value" or "name", so filtered out → falls back to method name + var cu = JParserTestUtils.parse("A", """ + import org.springframework.context.annotation.Bean; + class A { + @Bean(initMethod = "init") + Object myMethod() { return null; } + } + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.resolveBeanMethodNames(method)).containsOnly("myMethod"); + } + + @Test + void resolve_bean_method_names_blank_name_is_ignored_and_falls_back_to_method_name() { + var cu = JParserTestUtils.parse("A", """ + import org.springframework.context.annotation.Bean; + class A { + @Bean(name = "") + Object myMethod() { return null; } + } + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.resolveBeanMethodNames(method)).containsOnly("myMethod"); + } + + @Test + void resolve_bean_method_name_delegates_to_first_name() { + var cu = JParserTestUtils.parse("A", """ + import org.springframework.context.annotation.Bean; + class A { + @Bean(name = {"first", "second"}) + Object myMethod() { return null; } + } + """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); + var clazz = (ClassTreeImpl) cu.types().get(0); + var method = (MethodTreeImpl) clazz.members().get(0); + assertThat(SpringUtils.resolveBeanMethodName(method)).isEqualTo("first"); + } } From 0cfc9c4de10eecee9f9116f02914bf126a657af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Fri, 21 Aug 2026 16:53:51 +0200 Subject: [PATCH 06/13] Group similar tests into a single parameterized one --- .../org/sonar/java/utils/SpringUtilsTest.java | 63 +++++-------------- 1 file changed, 17 insertions(+), 46 deletions(-) diff --git a/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java b/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java index 8416c28101e..aafe47812a5 100644 --- a/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java +++ b/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java @@ -16,7 +16,11 @@ */ package org.sonar.java.utils; +import java.util.stream.Stream; 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 org.sonar.java.model.JParserTestUtils; import org.sonar.java.model.declaration.ClassTreeImpl; import org.sonar.java.model.declaration.MethodTreeImpl; @@ -176,14 +180,24 @@ class MyServiceImpl {} // ---- resolveBeanMethodNames ------------------------------------------------- - @Test - void resolve_bean_method_names_no_annotation_returns_method_name() { - var cu = JParserTestUtils.parse("class A { Object myMethod() { return null; } }"); + @ParameterizedTest(name = "{0}") + @MethodSource("fallBackToMethodNameArguments") + void resolve_bean_method_names_falls_back_to_method_name(String description, String source) { + var cu = JParserTestUtils.parse("A", source, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); var clazz = (ClassTreeImpl) cu.types().get(0); var method = (MethodTreeImpl) clazz.members().get(0); assertThat(SpringUtils.resolveBeanMethodNames(method)).containsOnly("myMethod"); } + static Stream fallBackToMethodNameArguments() { + return Stream.of( + Arguments.of("no annotation", "class A { Object myMethod() { return null; } }"), + Arguments.of("empty array", "import org.springframework.context.annotation.Bean; class A { @Bean(name = {}) Object myMethod() { return null; } }"), + Arguments.of("non-name/value attribute","import org.springframework.context.annotation.Bean; class A { @Bean(initMethod = \"init\") Object myMethod() { return null; } }"), + Arguments.of("blank name", "import org.springframework.context.annotation.Bean; class A { @Bean(name = \"\") Object myMethod() { return null; } }") + ); + } + @Test void resolve_bean_method_names_single_string_name() { var cu = JParserTestUtils.parse("A", """ @@ -212,49 +226,6 @@ class A { assertThat(SpringUtils.resolveBeanMethodNames(method)).containsExactlyInAnyOrder("primary", "alias"); } - @Test - void resolve_bean_method_names_empty_array_falls_back_to_method_name() { - var cu = JParserTestUtils.parse("A", """ - import org.springframework.context.annotation.Bean; - class A { - @Bean(name = {}) - Object myMethod() { return null; } - } - """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); - var clazz = (ClassTreeImpl) cu.types().get(0); - var method = (MethodTreeImpl) clazz.members().get(0); - assertThat(SpringUtils.resolveBeanMethodNames(method)).containsOnly("myMethod"); - } - - @Test - void resolve_bean_method_names_non_name_attribute_is_ignored() { - // @Bean(initMethod="...") — the "initMethod" attribute is not "value" or "name", so filtered out → falls back to method name - var cu = JParserTestUtils.parse("A", """ - import org.springframework.context.annotation.Bean; - class A { - @Bean(initMethod = "init") - Object myMethod() { return null; } - } - """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); - var clazz = (ClassTreeImpl) cu.types().get(0); - var method = (MethodTreeImpl) clazz.members().get(0); - assertThat(SpringUtils.resolveBeanMethodNames(method)).containsOnly("myMethod"); - } - - @Test - void resolve_bean_method_names_blank_name_is_ignored_and_falls_back_to_method_name() { - var cu = JParserTestUtils.parse("A", """ - import org.springframework.context.annotation.Bean; - class A { - @Bean(name = "") - Object myMethod() { return null; } - } - """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); - var clazz = (ClassTreeImpl) cu.types().get(0); - var method = (MethodTreeImpl) clazz.members().get(0); - assertThat(SpringUtils.resolveBeanMethodNames(method)).containsOnly("myMethod"); - } - @Test void resolve_bean_method_name_delegates_to_first_name() { var cu = JParserTestUtils.parse("A", """ From b2c8041610d5d4df2f6aa03b3cbbc13731cdaf99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Mon, 24 Aug 2026 11:49:11 +0200 Subject: [PATCH 07/13] Remove old sample file --- .../springcontext/QualifierOrderService.java | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 java-frontend/src/test/files/springcontext/QualifierOrderService.java diff --git a/java-frontend/src/test/files/springcontext/QualifierOrderService.java b/java-frontend/src/test/files/springcontext/QualifierOrderService.java deleted file mode 100644 index 8b9572c0065..00000000000 --- a/java-frontend/src/test/files/springcontext/QualifierOrderService.java +++ /dev/null @@ -1,17 +0,0 @@ -package checks.spring.context; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.context.ApplicationContext; -import org.springframework.stereotype.Service; - -@Service -class QualifierOrderService { - - private final ApplicationContext applicationContext; - - @Autowired - QualifierOrderService(@Qualifier("namedBean") ApplicationContext applicationContext) { - this.applicationContext = applicationContext; - } -} From 1c46239b7b1dfa966149f5fea0264951f58744ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Mon, 24 Aug 2026 14:42:26 +0200 Subject: [PATCH 08/13] Update tests --- .../TypeToBeanNamesIndexGathererTest.java | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java b/java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java index bcf5df67043..fc650a99670 100644 --- a/java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java +++ b/java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java @@ -86,7 +86,7 @@ void bean_method_return_type_is_registered() { void bean_method_aliases_are_all_registered() { scan("src/test/files/springcontext/ConfigurationWithBeanMethods.java"); - // @Bean(name = {"arrayNamedBean", "alias"}) — both names must appear in the index + // @Bean(name = {"arrayNamedBean", "alias"}) — both names appear in the index var index = model.getTypeToBeanNamesIndex(); assertThat(index.getNamesForType("org.springframework.context.ApplicationContext")) .contains("arrayNamedBean", "alias"); @@ -95,18 +95,17 @@ void bean_method_aliases_are_all_registered() { // ---- Multiple beans --------------------------------------------------------- @Test - void multiple_beans_of_same_type_all_registered() { + void multiple_beans_all_registered() { scan( - "src/test/files/springcontext/SimpleComponent.java", - "src/test/files/springcontext/SimpleService.java" + "src/test/files/springcontext/PayPalProcessor.java", + "src/test/files/springcontext/CreditCardProcessor.java" ); var index = model.getTypeToBeanNamesIndex(); - // Each bean appears only under its own concrete type - assertThat(index.getNamesForType("checks.spring.context.SimpleComponent")) - .containsOnly("simpleComponent"); - assertThat(index.getNamesForType("checks.spring.context.SimpleService")) - .containsOnly("simpleService"); + assertThat(index.getNamesForType("checks.spring.context.PayPalProcessor")) + .containsOnly("paypal"); + assertThat(index.getNamesForType("checks.spring.context.CreditCardProcessor")) + .containsOnly("creditCard"); } // ---- No annotation ---------------------------------------------------------- From eb384f1f366e32ec6990c20e7c26ee1163470d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Mon, 24 Aug 2026 15:12:56 +0200 Subject: [PATCH 09/13] Extract helper methods into SpringUtils --- .../springcontext/BeanDefinitionGatherer.java | 40 +------------------ .../org/sonar/java/utils/SpringUtils.java | 9 +++-- 2 files changed, 8 insertions(+), 41 deletions(-) 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 6b96c03d8bc..4b84343907b 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 @@ -16,7 +16,6 @@ */ package org.sonar.java.model.springcontext; -import java.beans.Introspector; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; @@ -114,8 +113,7 @@ public void visitNode(Tree tree) { String pkg = PackageUtils.packageNameOf(classTree.symbol()); if (SpringUtils.STEREOTYPE_ANNOTATIONS.stream().anyMatch(meta::isAnnotatedWith)) { - String beanName = extractBeanName(meta) - .orElseGet(() -> defaultBeanName(classTree.simpleName().name())); + String beanName = SpringUtils.resolveStereotypeBeanName(meta, classTree.simpleName().name()); Map> deps = collectAutowiredDependencies(classTree); // Class-level bean (stereotype annotations) var beanData = new BeanData( @@ -250,43 +248,9 @@ private static BeanData deserializeBean(String line, InputFile inputFile) { return new BeanData(beanName, type, beanPackage, inputFile, textSpan, isPrimary, deps); } - 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_ATTRIBUTE.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_ATTRIBUTE.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 beanName = SpringUtils.resolveBeanMethodName(method); String returnTypeFqn = method.returnType() != null ? method.returnType().symbolType().fullyQualifiedName() 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 2affb8e8f3e..ba147786861 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 @@ -106,9 +106,9 @@ public static String resolveStereotypeBeanName(SymbolMetadata meta, String simpl List attrs = meta.valuesForAnnotation(annotation); if (attrs != null) { Optional name = attrs.stream() - .filter(v -> VALUE_ATTRIBUTE.equals(v.name())) + .filter(v -> VALUE_ATTRIBUTE.equals(v.name()) || "name".equals(v.name())) .map(v -> (String) v.value()) - .filter(s -> s != null && !s.isBlank()) + .filter(s -> !s.isBlank()) .findFirst(); if (name.isPresent()) { return name.get(); @@ -131,9 +131,12 @@ public static List resolveBeanMethodNames(MethodTree method) { .filter(v -> VALUE_ATTRIBUTE.equals(v.name()) || "name".equals(v.name())) .flatMap(v -> { Object val = v.value(); - if (val instanceof Object[] arr) { + if (val instanceof Object[] arr && arr.length > 0) { return Arrays.stream(arr).filter(String.class::isInstance).map(String.class::cast); } + if (val instanceof String s) { + return Stream.of(s); + } return Stream.empty(); }) .filter(s -> !s.isBlank()) From 2dc95522d466dd33137dd19dfa82d2bad357bbaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Tue, 25 Aug 2026 10:20:54 +0200 Subject: [PATCH 10/13] Move implementation to BeanDefinitionGatherer --- .../springcontext/BeanDefinitionGatherer.java | 124 ++++++++++++--- .../SpringContextModelGatherers.java | 3 +- .../TypeToBeanNamesIndexGatherer.java | 98 ------------ .../org/sonar/java/utils/SpringUtils.java | 63 +------- .../BeanDefinitionGathererTest.java | 126 +++++++++++++--- .../TypeToBeanNamesIndexGathererTest.java | 141 ------------------ .../org/sonar/java/utils/SpringUtilsTest.java | 88 ----------- 7 files changed, 206 insertions(+), 437 deletions(-) delete mode 100644 java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java delete mode 100644 java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java 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 4b84343907b..4a76fb92ff2 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 @@ -16,6 +16,7 @@ */ package org.sonar.java.model.springcontext; +import java.beans.Introspector; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; @@ -27,6 +28,7 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nullable; @@ -37,7 +39,9 @@ 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.semantic.Type; import org.sonar.plugins.java.api.tree.ClassTree; import org.sonar.plugins.java.api.tree.MethodTree; import org.sonar.plugins.java.api.tree.Tree; @@ -59,7 +63,11 @@ *
  • {@code @Primary} designation
  • *
  • Dependencies via {@code @Autowired} fields, constructors, and setters for class-level beans
  • *
  • Dependencies via method parameters for {@code @Bean} method beans
  • + *
  • Implicit single-constructor injection (no {@code @Autowired} required)
  • * + * + *

    Also populates {@link TypeToBeanNamesIndex} with the full type hierarchy of each bean, + * so that rules can look up all beans assignable to a given type. */ public class BeanDefinitionGatherer extends SpringContextModelGatherer { @@ -71,6 +79,7 @@ public class BeanDefinitionGatherer extends SpringContextModelGatherer { private static final String DEP_SEPARATOR = ","; private static final String DEP_KEY_VALUE_SEPARATOR = ":"; private static final String DEP_NAMES_SEPARATOR = ";"; + private static final String TYPE_HIERARCHY_SEPARATOR = ";"; private static final String PRIMARY_ANNOTATION = "org.springframework.context.annotation.Primary"; private static final String VALUE_ATTRIBUTE = "value"; @@ -87,7 +96,8 @@ private record BeanData( InputFile inputFile, AnalyzerMessage.TextSpan textSpan, boolean isPrimary, - Map> dependingBeans) { + Map> dependingBeans, + Set typeHierarchy) { } @Override @@ -113,15 +123,17 @@ public void visitNode(Tree tree) { String pkg = PackageUtils.packageNameOf(classTree.symbol()); if (SpringUtils.STEREOTYPE_ANNOTATIONS.stream().anyMatch(meta::isAnnotatedWith)) { - String beanName = SpringUtils.resolveStereotypeBeanName(meta, classTree.simpleName().name()); + String beanName = extractBeanName(meta) + .orElseGet(() -> defaultBeanName(classTree.simpleName().name())); Map> deps = collectAutowiredDependencies(classTree); - // Class-level bean (stereotype annotations) + Set typeHierarchy = collectTypeHierarchy(classTree.symbol()); var beanData = new BeanData( beanName, fqn, pkg, context.getInputFile(), AnalyzerMessage.textSpanFor(classTree.simpleName()), meta.isAnnotatedWith(PRIMARY_ANNOTATION), - deps); + deps, + typeHierarchy); collectedBeans.add(beanData); beansCollectedAtFileLevel.add(beanData); @@ -165,6 +177,7 @@ private static String serializeBean(BeanData bean) { .map(n -> Base64.getEncoder().encodeToString(n.getBytes(StandardCharsets.UTF_8))) .collect(Collectors.joining(DEP_NAMES_SEPARATOR))) .collect(Collectors.joining(DEP_SEPARATOR)); + var typeHierarchy = String.join(TYPE_HIERARCHY_SEPARATOR, bean.typeHierarchy()); var span = bean.textSpan(); var encodedName = Base64.getEncoder().encodeToString(bean.beanName().getBytes(StandardCharsets.UTF_8)); return String.join(FIELD_SEPARATOR, @@ -173,7 +186,8 @@ private static String serializeBean(BeanData bean) { bean.beanPackage(), span.startLine + ":" + span.startCharacter + ":" + span.endLine + ":" + span.endCharacter, Boolean.toString(bean.isPrimary()), - deps); + deps, + typeHierarchy); } @Override @@ -188,6 +202,9 @@ public void gatherSpringContextData(ModuleScannerContext context, SpringContextM } springContextModel.getBeanDefinitionRegistry() .addBeanDefinition(data.beanName(), holderBuilder.build()); + for (String typeFqn : data.typeHierarchy()) { + springContextModel.getTypeToBeanNamesIndex().addBeanForType(typeFqn, data.beanName()); + } } } @@ -245,41 +262,82 @@ private static BeanData deserializeBean(String line, InputFile inputFile) { deps.put(typeFqn, names); } } - return new BeanData(beanName, type, beanPackage, inputFile, textSpan, isPrimary, deps); + Set typeHierarchy = fields.length > 6 && !fields[6].isEmpty() + ? new LinkedHashSet<>(List.of(fields[6].split(TYPE_HIERARCHY_SEPARATOR))) + : new LinkedHashSet<>(); + return new BeanData(beanName, type, beanPackage, inputFile, textSpan, isPrimary, deps, typeHierarchy); + } + + 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_ATTRIBUTE.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(); - String beanName = SpringUtils.resolveBeanMethodName(method); + List attrs = beanMeta.valuesForAnnotation(SpringUtils.BEAN_ANNOTATION); + List beanNames = Optional.ofNullable(attrs) + .map(list -> list.stream() + .filter(v -> VALUE_ATTRIBUTE.equals(v.name()) || "name".equals(v.name())) + .flatMap(v -> { + Object val = v.value(); + if (val instanceof Object[] arr && arr.length > 0) { + return Arrays.stream(arr).filter(String.class::isInstance).map(String.class::cast); + } + return Stream.empty(); + }) + .filter(s -> !s.isBlank()) + .toList()) + .filter(names -> !names.isEmpty()) + .orElse(List.of(method.simpleName().name())); String returnTypeFqn = method.returnType() != null ? method.returnType().symbolType().fullyQualifiedName() : ""; + Set typeHierarchy = method.returnType() != null + ? collectTypeHierarchy(method.returnType().symbolType().symbol()) + : Set.of(); Map> paramDeps = parameterDependencies(method); + boolean isPrimary = beanMeta.isAnnotatedWith(PRIMARY_ANNOTATION); + var textSpan = AnalyzerMessage.textSpanFor(method.simpleName()); + var inputFile = context.getInputFile(); - var beanData = new BeanData( - beanName, returnTypeFqn, pkg, - context.getInputFile(), - AnalyzerMessage.textSpanFor(method.simpleName()), - beanMeta.isAnnotatedWith(PRIMARY_ANNOTATION), - paramDeps); - collectedBeans.add(beanData); - beansCollectedAtFileLevel.add(beanData); + for (String beanName : beanNames) { + var beanData = new BeanData(beanName, returnTypeFqn, pkg, inputFile, textSpan, isPrimary, paramDeps, typeHierarchy); + collectedBeans.add(beanData); + beansCollectedAtFileLevel.add(beanData); + } } private static Map> collectAutowiredDependencies(ClassTree classTree) { Map> deps = new LinkedHashMap<>(); List unannotatedConstructors = new ArrayList<>(); - boolean hasAutowiredConstructor = false; for (Tree member : classTree.members()) { - if (member instanceof VariableTree field && field.symbol().metadata().isAnnotatedWith(SpringUtils.AUTOWIRED_ANNOTATION)) { - String typeFqn = field.symbol().type().fullyQualifiedName(); - String name = dependencyKey(field.simpleName().name(), extractQualifier(field.symbol().metadata())); - deps.computeIfAbsent(typeFqn, k -> new LinkedHashSet<>()).add(name); + if (member instanceof VariableTree field) { + if (field.symbol().metadata().isAnnotatedWith(SpringUtils.AUTOWIRED_ANNOTATION)) { + String typeFqn = field.symbol().type().fullyQualifiedName(); + String name = dependencyKey(field.simpleName().name(), extractQualifier(field.symbol().metadata())); + deps.computeIfAbsent(typeFqn, k -> new LinkedHashSet<>()).add(name); + } } else if (member instanceof MethodTree method) { if (method.symbol().metadata().isAnnotatedWith(SpringUtils.AUTOWIRED_ANNOTATION)) { - hasAutowiredConstructor |= method.is(Tree.Kind.CONSTRUCTOR); parameterDependencies(method).forEach((type, names) -> deps.computeIfAbsent(type, k -> new LinkedHashSet<>()).addAll(names)); } else if (method.is(Tree.Kind.CONSTRUCTOR)) { @@ -287,7 +345,7 @@ private static Map> collectAutowiredDependencies(ClassTree c } } } - if (!hasAutowiredConstructor && unannotatedConstructors.size() == 1) { + if (deps.isEmpty() && unannotatedConstructors.size() == 1) { parameterDependencies(unannotatedConstructors.get(0)).forEach((type, names) -> deps.computeIfAbsent(type, k -> new LinkedHashSet<>()).addAll(names)); } @@ -322,4 +380,26 @@ private static String extractQualifier(SymbolMetadata metadata) { .orElse(null); } + private static Set collectTypeHierarchy(Symbol.TypeSymbol symbol) { + Set visited = new LinkedHashSet<>(); + walkTypeHierarchy(symbol, visited); + return visited; + } + + private static void walkTypeHierarchy(Symbol.TypeSymbol symbol, Set visited) { + String fqn = symbol.type().fullyQualifiedName(); + if ("java.lang.Object".equals(fqn) || symbol.type().isUnknown() || !visited.add(fqn)) { + return; + } + Type superClass = symbol.superClass(); + if (superClass != null && !superClass.isUnknown()) { + walkTypeHierarchy(superClass.symbol(), visited); + } + for (Type iface : symbol.interfaces()) { + if (!iface.isUnknown()) { + walkTypeHierarchy(iface.symbol(), visited); + } + } + } + } 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 257f9e200ac..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 @@ -40,8 +40,7 @@ private SpringContextModelGatherers() { public static List getAllGatherers() { return List.of( new ComponentScanPackageGatherer(), - new BeanDefinitionGatherer(), - new TypeToBeanNamesIndexGatherer() + new BeanDefinitionGatherer() ); } diff --git a/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java b/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java deleted file mode 100644 index 268f06e5770..00000000000 --- a/java-frontend/src/main/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGatherer.java +++ /dev/null @@ -1,98 +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.springcontext; - -import java.util.ArrayList; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; -import org.sonar.java.utils.SpringUtils; -import org.sonar.plugins.java.api.ModuleScannerContext; -import org.sonar.plugins.java.api.semantic.Symbol; -import org.sonar.plugins.java.api.semantic.Type; -import org.sonar.plugins.java.api.tree.ClassTree; -import org.sonar.plugins.java.api.tree.MethodTree; -import org.sonar.plugins.java.api.tree.Tree; - -/** - * Populates {@link TypeToBeanNamesIndex} by mapping every type in a bean's hierarchy - * (concrete class, superclasses, interfaces) to the bean's name. - */ -public class TypeToBeanNamesIndexGatherer extends SpringContextModelGatherer { - - private record BeanTypeEntry(String beanName, Set typeHierarchy) {} - - private final List collectedEntries = new ArrayList<>(); - - @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; - } - - var meta = classTree.symbol().metadata(); - if (SpringUtils.STEREOTYPE_ANNOTATIONS.stream().anyMatch(meta::isAnnotatedWith)) { - String beanName = SpringUtils.resolveStereotypeBeanName(meta, classTree.simpleName().name()); - collectedEntries.add(new BeanTypeEntry(beanName, collectTypeHierarchy(classTree.symbol()))); - - for (MethodTree method : SpringUtils.getBeanMethods(classTree)) { - Set typeHierarchy = collectTypeHierarchy(method.returnType().symbolType().symbol()); - for (String methodBeanName : SpringUtils.resolveBeanMethodNames(method)) { - collectedEntries.add(new BeanTypeEntry(methodBeanName, typeHierarchy)); - } - } - } - } - - @Override - public void gatherSpringContextData(ModuleScannerContext context, SpringContextModel springContextModel) { - TypeToBeanNamesIndex index = springContextModel.getTypeToBeanNamesIndex(); - for (BeanTypeEntry entry : collectedEntries) { - for (String typeFqn : entry.typeHierarchy()) { - index.addBeanForType(typeFqn, entry.beanName()); - } - } - } - - private static Set collectTypeHierarchy(Symbol.TypeSymbol symbol) { - Set visited = new LinkedHashSet<>(); - walkTypeHierarchy(symbol, visited); - return visited; - } - - private static void walkTypeHierarchy(Symbol.TypeSymbol symbol, Set visited) { - String fqn = symbol.type().fullyQualifiedName(); - if ("java.lang.Object".equals(fqn) || symbol.type().isUnknown() || !visited.add(fqn)) { - return; - } - Type superClass = symbol.superClass(); - if (superClass != null && !superClass.isUnknown()) { - walkTypeHierarchy(superClass.symbol(), visited); - } - for (Type iface : symbol.interfaces()) { - if (!iface.isUnknown()) { - walkTypeHierarchy(iface.symbol(), visited); - } - } - } -} 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 ba147786861..9673af815dd 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 @@ -16,11 +16,7 @@ */ package org.sonar.java.utils; -import java.beans.Introspector; -import java.util.Arrays; import java.util.List; -import java.util.Optional; -import java.util.stream.Stream; import org.sonar.java.model.ExpressionUtils; import org.sonar.plugins.java.api.semantic.Symbol; @@ -57,8 +53,6 @@ public final class SpringUtils { CONFIGURATION_ANNOTATION ); - public static final String VALUE_ATTRIBUTE = "value"; - private SpringUtils() { // Utils class } @@ -70,7 +64,7 @@ public static boolean isScopeSingleton(SymbolMetadata clazzMeta) { return true; } for (SymbolMetadata.AnnotationValue annotationValue : values) { - if (VALUE_ATTRIBUTE.equals(annotationValue.name()) || "scopeName".equals(annotationValue.name())) { + if ("value".equals(annotationValue.name()) || "scopeName".equals(annotationValue.name())) { Object value = annotationValue.value(); if (value instanceof String stringValue && !"singleton".equals(stringValue)) { return false; @@ -97,61 +91,6 @@ public static boolean isSpringBootUnitTest(MethodTree methodTree) { return UnitTestUtils.isUnitTest(methodTree) && SpringUtils.isSpringBootTestClass(parentClass.symbol()); } - /** - * Resolves the Spring bean name for a stereotype-annotated class. - * Returns the explicit name from the annotation if present, otherwise the decapitalized simple class name. - */ - public static String resolveStereotypeBeanName(SymbolMetadata meta, String simpleName) { - for (String annotation : STEREOTYPE_ANNOTATIONS) { - List attrs = meta.valuesForAnnotation(annotation); - if (attrs != null) { - Optional name = attrs.stream() - .filter(v -> VALUE_ATTRIBUTE.equals(v.name()) || "name".equals(v.name())) - .map(v -> (String) v.value()) - .filter(s -> !s.isBlank()) - .findFirst(); - if (name.isPresent()) { - return name.get(); - } - } - } - return Introspector.decapitalize(simpleName); - } - - /** - * Resolves all Spring bean names for a {@code @Bean} factory method, including aliases. - * Returns the explicit names from the annotation if present, otherwise a singleton list of the method name. - */ - public static List resolveBeanMethodNames(MethodTree method) { - List attrs = method.symbol().metadata().valuesForAnnotation(BEAN_ANNOTATION); - if (attrs == null) { - return List.of(method.simpleName().name()); - } - List names = attrs.stream() - .filter(v -> VALUE_ATTRIBUTE.equals(v.name()) || "name".equals(v.name())) - .flatMap(v -> { - Object val = v.value(); - if (val instanceof Object[] arr && arr.length > 0) { - return Arrays.stream(arr).filter(String.class::isInstance).map(String.class::cast); - } - if (val instanceof String s) { - return Stream.of(s); - } - return Stream.empty(); - }) - .filter(s -> !s.isBlank()) - .toList(); - return names.isEmpty() ? List.of(method.simpleName().name()) : names; - } - - /** - * Resolves the primary Spring bean name for a {@code @Bean} factory method. - * Returns the first explicit name from the annotation if present, otherwise the method name. - */ - public static String resolveBeanMethodName(MethodTree method) { - return resolveBeanMethodNames(method).get(0); - } - public static List getBeanMethods(ClassTree classTree) { return classTree.members().stream() .filter(member -> member.is(Tree.Kind.METHOD)) 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 a890b5bee19..f898855452a 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 @@ -26,6 +26,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.sonar.api.batch.fs.InputFile; import org.sonar.api.batch.sensor.cache.WriteCache; @@ -36,7 +37,6 @@ import org.sonar.plugins.java.api.caching.CacheContext; import org.sonar.plugins.java.api.caching.JavaReadCache; import org.sonar.plugins.java.api.caching.JavaWriteCache; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.any; @@ -202,13 +202,11 @@ 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"), - Arguments.of("src/test/files/springcontext/SingleConstructorDependencies.java", "singleConstructorDependencies") + Arguments.of("src/test/files/springcontext/SingleConstructorDependencies.java", "singleConstructorDependencies"), + Arguments.of("src/test/files/springcontext/BeanMethodWithDependencies.java", "myBean") ); } - // ---- Implicit single-constructor injection -------------------------------- - @Test void multiple_constructors_without_autowired_yields_no_dependencies() { scan("src/test/files/springcontext/MultipleConstructorsNoDependencies.java"); @@ -218,25 +216,6 @@ void multiple_constructors_without_autowired_yields_no_dependencies() { assertThat(beans.get(0).getDependingBeans()).isEmpty(); } - @ParameterizedTest(name = "{0}") - @MethodSource("mixedInjectionArguments") - void both_injection_sources_collected_when_mixing_injection_styles(String filePath, String beanName) { - scan(filePath); - - var beans = model.getBeanDefinitionRegistry().getByName(beanName); - assertThat(beans).hasSize(1); - var deps = beans.get(0).getDependingBeans(); - assertThat(deps.get("org.springframework.context.ApplicationContext")).containsOnly("applicationContext"); - assertThat(deps.get("org.springframework.core.env.Environment")).containsOnly("environment"); - } - - static Stream mixedInjectionArguments() { - return Stream.of( - Arguments.of("src/test/files/springcontext/AutowiredConstructorWithUnannotatedConstructor.java", "autowiredConstructorWithUnannotatedConstructor"), - Arguments.of("src/test/files/springcontext/MixedInjectionDependencies.java", "mixedInjectionDependencies") - ); - } - // ---- @Qualifier handling -------------------------------------------------- @ParameterizedTest(name = "{0}") @@ -496,6 +475,105 @@ void blank_qualifier_value_is_treated_as_no_qualifier() { assertThat(deps.get("org.springframework.context.ApplicationContext")).containsOnly("applicationContext"); } + // ---- TypeToBeanNamesIndex ------------------------------------------------- + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "src/test/files/springcontext/SimpleComponent.java", + "src/test/files/springcontext/SimpleService.java", + "src/test/files/springcontext/SimpleRepository.java", + "src/test/files/springcontext/SimpleController.java", + "src/test/files/springcontext/SimpleRestController.java", + "src/test/files/springcontext/SimpleConfiguration.java" + }) + void stereotype_bean_is_registered_under_its_own_type(String filePath) { + scan(filePath); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("checks.spring.context." + beanClassNameFrom(filePath))) + .isNotEmpty(); + } + + @Test + void bean_is_registered_under_implemented_interface() { + scan("src/test/files/springcontext/ComponentImplementingInterface.java"); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("checks.spring.context.ComponentImplementingInterface")) + .containsOnly("componentImplementingInterface"); + assertThat(index.getNamesForType("org.springframework.context.ApplicationContextAware")) + .containsOnly("componentImplementingInterface"); + } + + @Test + void explicit_bean_name_is_used_in_index() { + scan("src/test/files/springcontext/ExplicitNameComponent.java"); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("checks.spring.context.ExplicitNameComponent")) + .containsOnly("myBean"); + } + + @Test + void bean_method_return_type_is_registered() { + scan("src/test/files/springcontext/ConfigurationWithBeanMethods.java"); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("org.springframework.context.ApplicationContext")) + .contains("simpleServiceBean", "namedBean", "arrayNamedBean", "emptyNameArrayMethod"); + } + + @Test + void bean_method_aliases_are_all_registered() { + scan("src/test/files/springcontext/ConfigurationWithBeanMethods.java"); + + // @Bean(name = {"arrayNamedBean", "alias"}) — both names appear in the index + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("org.springframework.context.ApplicationContext")) + .contains("arrayNamedBean", "alias"); + } + + @Test + void multiple_beans_all_registered_in_index() { + scan( + "src/test/files/springcontext/PayPalProcessor.java", + "src/test/files/springcontext/CreditCardProcessor.java" + ); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("checks.spring.context.PayPalProcessor")) + .containsOnly("paypal"); + assertThat(index.getNamesForType("checks.spring.context.CreditCardProcessor")) + .containsOnly("creditCard"); + } + + @Test + void non_spring_class_registers_nothing_in_index() { + scan("src/test/files/springcontext/NoScanAnnotations.java"); + + assertThat(model.getTypeToBeanNamesIndex().getNamesForType("checks.spring.context.NoScanAnnotations")) + .isEmpty(); + } + + @Test + void index_gatherer_skipped_when_spring_not_in_classpath() { + scan(List.of(), "src/test/files/springcontext/SimpleComponent.java"); + + assertThat(model.getTypeToBeanNamesIndex().getNamesForType("checks.spring.context.SimpleComponent")) + .isEmpty(); + } + + @Test + void index_anonymous_class_is_skipped() { + scan("src/test/files/springcontext/SpringBootAppWithAnonymousClass.java"); + + assertThat(model.getTypeToBeanNamesIndex().getNamesForType("")).isEmpty(); + } + + private static String beanClassNameFrom(String filePath) { + return filePath.substring(filePath.lastIndexOf('/') + 1, filePath.lastIndexOf('.')); + } + 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/TypeToBeanNamesIndexGathererTest.java b/java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java deleted file mode 100644 index fc650a99670..00000000000 --- a/java-frontend/src/test/java/org/sonar/java/model/springcontext/TypeToBeanNamesIndexGathererTest.java +++ /dev/null @@ -1,141 +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.springcontext; - -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import static org.assertj.core.api.Assertions.assertThat; - -class TypeToBeanNamesIndexGathererTest extends SpringContextGathererTest { - - @BeforeEach - void setUp() { - gatherer = new TypeToBeanNamesIndexGatherer(); - model = new SpringContextModel(); - } - - // ---- Stereotype beans ------------------------------------------------------- - - @ParameterizedTest(name = "{0}") - @ValueSource(strings = { - "src/test/files/springcontext/SimpleComponent.java", - "src/test/files/springcontext/SimpleService.java", - "src/test/files/springcontext/SimpleRepository.java", - "src/test/files/springcontext/SimpleController.java", - "src/test/files/springcontext/SimpleRestController.java", - "src/test/files/springcontext/SimpleConfiguration.java" - }) - void stereotype_bean_is_registered_under_its_own_type(String filePath) { - scan(filePath); - - var index = model.getTypeToBeanNamesIndex(); - assertThat(index.getNamesForType("checks.spring.context." + beanClassNameFrom(filePath))) - .isNotEmpty(); - } - - @Test - void bean_is_registered_under_implemented_interface() { - scan("src/test/files/springcontext/ComponentImplementingInterface.java"); - - var index = model.getTypeToBeanNamesIndex(); - assertThat(index.getNamesForType("checks.spring.context.ComponentImplementingInterface")) - .containsOnly("componentImplementingInterface"); - assertThat(index.getNamesForType("org.springframework.context.ApplicationContextAware")) - .containsOnly("componentImplementingInterface"); - } - - @Test - void explicit_bean_name_is_used_in_index() { - scan("src/test/files/springcontext/ExplicitNameComponent.java"); - - var index = model.getTypeToBeanNamesIndex(); - assertThat(index.getNamesForType("checks.spring.context.ExplicitNameComponent")) - .containsOnly("myBean"); - } - - // ---- @Bean methods ---------------------------------------------------------- - - @Test - void bean_method_return_type_is_registered() { - scan("src/test/files/springcontext/ConfigurationWithBeanMethods.java"); - - var index = model.getTypeToBeanNamesIndex(); - assertThat(index.getNamesForType("org.springframework.context.ApplicationContext")) - .contains("simpleServiceBean", "namedBean", "arrayNamedBean", "emptyNameArrayMethod"); - } - - @Test - void bean_method_aliases_are_all_registered() { - scan("src/test/files/springcontext/ConfigurationWithBeanMethods.java"); - - // @Bean(name = {"arrayNamedBean", "alias"}) — both names appear in the index - var index = model.getTypeToBeanNamesIndex(); - assertThat(index.getNamesForType("org.springframework.context.ApplicationContext")) - .contains("arrayNamedBean", "alias"); - } - - // ---- Multiple beans --------------------------------------------------------- - - @Test - void multiple_beans_all_registered() { - scan( - "src/test/files/springcontext/PayPalProcessor.java", - "src/test/files/springcontext/CreditCardProcessor.java" - ); - - var index = model.getTypeToBeanNamesIndex(); - assertThat(index.getNamesForType("checks.spring.context.PayPalProcessor")) - .containsOnly("paypal"); - assertThat(index.getNamesForType("checks.spring.context.CreditCardProcessor")) - .containsOnly("creditCard"); - } - - // ---- No annotation ---------------------------------------------------------- - - @Test - void non_spring_class_registers_nothing() { - scan("src/test/files/springcontext/NoScanAnnotations.java"); - - assertThat(model.getTypeToBeanNamesIndex().getNamesForType("checks.spring.context.NoScanAnnotations")) - .isEmpty(); - } - - @Test - void gatherer_skipped_when_spring_not_in_classpath() { - scan(List.of(), "src/test/files/springcontext/SimpleComponent.java"); - - assertThat(model.getTypeToBeanNamesIndex().getNamesForType("checks.spring.context.SimpleComponent")) - .isEmpty(); - } - - @Test - void anonymous_class_is_skipped() { - scan("src/test/files/springcontext/SpringBootAppWithAnonymousClass.java"); - - assertThat(model.getTypeToBeanNamesIndex().getNamesForType("")).isEmpty(); - } - - // ---- Helpers ---------------------------------------------------------------- - - private static String beanClassNameFrom(String filePath) { - return filePath.substring(filePath.lastIndexOf('/') + 1, filePath.lastIndexOf('.')); - } -} diff --git a/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java b/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java index aafe47812a5..b084c691e9a 100644 --- a/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java +++ b/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java @@ -16,11 +16,7 @@ */ package org.sonar.java.utils; -import java.util.stream.Stream; 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 org.sonar.java.model.JParserTestUtils; import org.sonar.java.model.declaration.ClassTreeImpl; import org.sonar.java.model.declaration.MethodTreeImpl; @@ -155,88 +151,4 @@ void myTest() {} assertThat(SpringUtils.isSpringBootUnitTest(method)).isFalse(); } - // ---- resolveStereotypeBeanName ---------------------------------------------- - - @Test - void resolve_stereotype_bean_name_uses_name_attribute() { - // Covers the "name".equals(v.name()) branch in the filter - var cu = JParserTestUtils.parse("A", """ - @org.springframework.stereotype.Service(value = "myService") - class A {} - """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); - var clazz = (ClassTreeImpl) cu.types().get(0); - assertThat(SpringUtils.resolveStereotypeBeanName(clazz.symbol().metadata(), "A")).isEqualTo("myService"); - } - - @Test - void resolve_stereotype_bean_name_falls_back_to_decapitalized_name() { - var cu = JParserTestUtils.parse("A", """ - @org.springframework.stereotype.Component - class MyServiceImpl {} - """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); - var clazz = (ClassTreeImpl) cu.types().get(0); - assertThat(SpringUtils.resolveStereotypeBeanName(clazz.symbol().metadata(), "MyServiceImpl")).isEqualTo("myServiceImpl"); - } - - // ---- resolveBeanMethodNames ------------------------------------------------- - - @ParameterizedTest(name = "{0}") - @MethodSource("fallBackToMethodNameArguments") - void resolve_bean_method_names_falls_back_to_method_name(String description, String source) { - var cu = JParserTestUtils.parse("A", source, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); - var clazz = (ClassTreeImpl) cu.types().get(0); - var method = (MethodTreeImpl) clazz.members().get(0); - assertThat(SpringUtils.resolveBeanMethodNames(method)).containsOnly("myMethod"); - } - - static Stream fallBackToMethodNameArguments() { - return Stream.of( - Arguments.of("no annotation", "class A { Object myMethod() { return null; } }"), - Arguments.of("empty array", "import org.springframework.context.annotation.Bean; class A { @Bean(name = {}) Object myMethod() { return null; } }"), - Arguments.of("non-name/value attribute","import org.springframework.context.annotation.Bean; class A { @Bean(initMethod = \"init\") Object myMethod() { return null; } }"), - Arguments.of("blank name", "import org.springframework.context.annotation.Bean; class A { @Bean(name = \"\") Object myMethod() { return null; } }") - ); - } - - @Test - void resolve_bean_method_names_single_string_name() { - var cu = JParserTestUtils.parse("A", """ - import org.springframework.context.annotation.Bean; - class A { - @Bean(name = "myBean") - Object myMethod() { return null; } - } - """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); - var clazz = (ClassTreeImpl) cu.types().get(0); - var method = (MethodTreeImpl) clazz.members().get(0); - assertThat(SpringUtils.resolveBeanMethodNames(method)).containsOnly("myBean"); - } - - @Test - void resolve_bean_method_names_array_includes_all_aliases() { - var cu = JParserTestUtils.parse("A", """ - import org.springframework.context.annotation.Bean; - class A { - @Bean(name = {"primary", "alias"}) - Object myMethod() { return null; } - } - """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); - var clazz = (ClassTreeImpl) cu.types().get(0); - var method = (MethodTreeImpl) clazz.members().get(0); - assertThat(SpringUtils.resolveBeanMethodNames(method)).containsExactlyInAnyOrder("primary", "alias"); - } - - @Test - void resolve_bean_method_name_delegates_to_first_name() { - var cu = JParserTestUtils.parse("A", """ - import org.springframework.context.annotation.Bean; - class A { - @Bean(name = {"first", "second"}) - Object myMethod() { return null; } - } - """, TestClasspathUtils.DEFAULT_MODULE.getClassPath()); - var clazz = (ClassTreeImpl) cu.types().get(0); - var method = (MethodTreeImpl) clazz.members().get(0); - assertThat(SpringUtils.resolveBeanMethodName(method)).isEqualTo("first"); - } } From 1cbb1b9fbac73a099786658b50afb3cc2ee2331f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Tue, 25 Aug 2026 10:58:33 +0200 Subject: [PATCH 11/13] Fix single constructor injection handling --- .../springcontext/BeanDefinitionGatherer.java | 14 +++++++------- .../BeanDefinitionGathererTest.java | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) 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 4a76fb92ff2..eb6dab9327c 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 @@ -329,15 +329,15 @@ private void collectBeanMethod(MethodTree method, String pkg) { private static Map> collectAutowiredDependencies(ClassTree classTree) { Map> deps = new LinkedHashMap<>(); List unannotatedConstructors = new ArrayList<>(); + boolean hasAutowiredConstructor = false; for (Tree member : classTree.members()) { - if (member instanceof VariableTree field) { - if (field.symbol().metadata().isAnnotatedWith(SpringUtils.AUTOWIRED_ANNOTATION)) { - String typeFqn = field.symbol().type().fullyQualifiedName(); - String name = dependencyKey(field.simpleName().name(), extractQualifier(field.symbol().metadata())); - deps.computeIfAbsent(typeFqn, k -> new LinkedHashSet<>()).add(name); - } + if (member instanceof VariableTree field && field.symbol().metadata().isAnnotatedWith(SpringUtils.AUTOWIRED_ANNOTATION)) { + String typeFqn = field.symbol().type().fullyQualifiedName(); + String name = dependencyKey(field.simpleName().name(), extractQualifier(field.symbol().metadata())); + deps.computeIfAbsent(typeFqn, k -> new LinkedHashSet<>()).add(name); } else if (member instanceof MethodTree method) { if (method.symbol().metadata().isAnnotatedWith(SpringUtils.AUTOWIRED_ANNOTATION)) { + hasAutowiredConstructor |= method.is(Tree.Kind.CONSTRUCTOR); parameterDependencies(method).forEach((type, names) -> deps.computeIfAbsent(type, k -> new LinkedHashSet<>()).addAll(names)); } else if (method.is(Tree.Kind.CONSTRUCTOR)) { @@ -345,7 +345,7 @@ private static Map> collectAutowiredDependencies(ClassTree c } } } - if (deps.isEmpty() && unannotatedConstructors.size() == 1) { + if (!hasAutowiredConstructor && unannotatedConstructors.size() == 1) { parameterDependencies(unannotatedConstructors.get(0)).forEach((type, names) -> deps.computeIfAbsent(type, k -> new LinkedHashSet<>()).addAll(names)); } 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 f898855452a..28d3ddf48e9 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 @@ -216,6 +216,25 @@ void multiple_constructors_without_autowired_yields_no_dependencies() { assertThat(beans.get(0).getDependingBeans()).isEmpty(); } + @ParameterizedTest(name = "{0}") + @MethodSource("mixedInjectionArguments") + void both_injection_sources_collected_when_mixing_injection_styles(String filePath, String beanName) { + scan(filePath); + + var beans = model.getBeanDefinitionRegistry().getByName(beanName); + assertThat(beans).hasSize(1); + var deps = beans.get(0).getDependingBeans(); + assertThat(deps.get("org.springframework.context.ApplicationContext")).containsOnly("applicationContext"); + assertThat(deps.get("org.springframework.core.env.Environment")).containsOnly("environment"); + } + + static Stream mixedInjectionArguments() { + return Stream.of( + Arguments.of("src/test/files/springcontext/AutowiredConstructorWithUnannotatedConstructor.java", "autowiredConstructorWithUnannotatedConstructor"), + Arguments.of("src/test/files/springcontext/MixedInjectionDependencies.java", "mixedInjectionDependencies") + ); + } + // ---- @Qualifier handling -------------------------------------------------- @ParameterizedTest(name = "{0}") From ed4373865d491491adf3a8cb6a4b955fb3785743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Tue, 25 Aug 2026 11:24:00 +0200 Subject: [PATCH 12/13] Fix cache for type index --- .../java/model/springcontext/BeanDefinitionGatherer.java | 2 +- .../java/model/springcontext/BeanDefinitionGathererTest.java | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) 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 eb6dab9327c..c0f7f4a3768 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 @@ -262,7 +262,7 @@ private static BeanData deserializeBean(String line, InputFile inputFile) { deps.put(typeFqn, names); } } - Set typeHierarchy = fields.length > 6 && !fields[6].isEmpty() + Set typeHierarchy = !fields[6].isEmpty() ? new LinkedHashSet<>(List.of(fields[6].split(TYPE_HIERARCHY_SEPARATOR))) : new LinkedHashSet<>(); return new BeanData(beanName, type, beanPackage, inputFile, textSpan, isPrimary, deps, typeHierarchy); 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 28d3ddf48e9..1d1040b7981 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 @@ -340,7 +340,7 @@ void scanWithoutParsing_returns_true_and_restores_beans_on_cache_hit() { InputFile inputFile = TestUtils.inputFile(new File("src/test/files/springcontext/SimpleComponent.java")); String cacheKey = "java:spring:bean-definitions:" + inputFile.key(); String encodedName = Base64.getEncoder().encodeToString("simpleComponent".getBytes(StandardCharsets.UTF_8)); - String serialized = encodedName + "|checks.spring.context.SimpleComponent|checks.spring.context|6:6:6:21|false|"; + String serialized = encodedName + "|checks.spring.context.SimpleComponent|checks.spring.context|6:6:6:21|false||checks.spring.context.SimpleComponent"; JavaReadCache readCache = mock(JavaReadCache.class); when(readCache.readBytes(cacheKey)).thenReturn(serialized.getBytes(StandardCharsets.UTF_8)); @@ -460,7 +460,8 @@ void scanWithoutParsing_restores_dependencies_with_and_without_qualifier_from_ca String encodedEnvironment = Base64.getEncoder().encodeToString("environment".getBytes(StandardCharsets.UTF_8)); String serialized = encodedName + "|checks.spring.context.QualifiedFieldDependencies|checks.spring.context|10:6:10:30|false|" + encodedAppContext + ":" + encodedPrimaryContext - + "," + encodedEnvType + ":" + encodedEnvironment; + + "," + encodedEnvType + ":" + encodedEnvironment + + "|checks.spring.context.QualifiedFieldDependencies"; JavaReadCache readCache = mock(JavaReadCache.class); when(readCache.readBytes(cacheKey)).thenReturn(serialized.getBytes(StandardCharsets.UTF_8)); From 2f87eb679a8fcdf981c6b059a9619f3622e7196f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9mie=20B=C3=A9nard?= Date: Tue, 25 Aug 2026 12:03:57 +0200 Subject: [PATCH 13/13] Improve tests --- ...ConstructorWithUnannotatedConstructor.java | 4 +- .../BeanDefinitionGathererTest.java | 50 +++++++++++++++---- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/java-frontend/src/test/files/springcontext/AutowiredConstructorWithUnannotatedConstructor.java b/java-frontend/src/test/files/springcontext/AutowiredConstructorWithUnannotatedConstructor.java index e9512a02c61..2ba2e86ad9d 100644 --- a/java-frontend/src/test/files/springcontext/AutowiredConstructorWithUnannotatedConstructor.java +++ b/java-frontend/src/test/files/springcontext/AutowiredConstructorWithUnannotatedConstructor.java @@ -18,8 +18,8 @@ class AutowiredConstructorWithUnannotatedConstructor { } // Spring ignores this constructor — its parameters must not appear as dependencies - AutowiredConstructorWithUnannotatedConstructor(ApplicationContext applicationContext) { - this.applicationContext = applicationContext; + AutowiredConstructorWithUnannotatedConstructor(ApplicationContext ignoredContext) { + this.applicationContext = ignoredContext; this.environment = null; } } 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 1d1040b7981..4e02bbcf04c 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 @@ -151,6 +151,7 @@ void anonymous_class_is_skipped() { // 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(); + assertThat(model.getTypeToBeanNamesIndex().getNamesForType("")).isEmpty(); } @Test @@ -332,7 +333,8 @@ void leaveFile_writes_beans_to_cache() { .contains(encodedName) .contains("checks.spring.context.SimpleComponent") .contains("checks.spring.context") - .contains("false"); + .contains("false") + .endsWith("|checks.spring.context.SimpleComponent"); } @Test @@ -360,6 +362,8 @@ void scanWithoutParsing_returns_true_and_restores_beans_on_cache_hit() { assertThat(beans).hasSize(1); assertThat(beans.get(0).getType()).isEqualTo("checks.spring.context.SimpleComponent"); assertThat(beans.get(0).isPrimary()).isFalse(); + assertThat(model.getTypeToBeanNamesIndex().getNamesForType("checks.spring.context.SimpleComponent")) + .containsOnly("simpleComponent"); } @Test @@ -515,7 +519,7 @@ void stereotype_bean_is_registered_under_its_own_type(String filePath) { } @Test - void bean_is_registered_under_implemented_interface() { + void bean_is_registered_under_full_type_hierarchy() { scan("src/test/files/springcontext/ComponentImplementingInterface.java"); var index = model.getTypeToBeanNamesIndex(); @@ -523,6 +527,41 @@ void bean_is_registered_under_implemented_interface() { .containsOnly("componentImplementingInterface"); assertThat(index.getNamesForType("org.springframework.context.ApplicationContextAware")) .containsOnly("componentImplementingInterface"); + assertThat(index.getNamesForType("org.springframework.beans.factory.Aware")) + .containsOnly("componentImplementingInterface"); + } + + @Test + void scanWithoutParsing_restores_full_type_hierarchy_from_cache() { + InputFile inputFile = TestUtils.inputFile(new File("src/test/files/springcontext/ComponentImplementingInterface.java")); + String cacheKey = "java:spring:bean-definitions:" + inputFile.key(); + String encodedName = Base64.getEncoder().encodeToString("componentImplementingInterface".getBytes(StandardCharsets.UTF_8)); + String serialized = encodedName + "|checks.spring.context.ComponentImplementingInterface|checks.spring.context|8:6:8:36|false|" + + "|checks.spring.context.ComponentImplementingInterface" + + ";org.springframework.context.ApplicationContextAware" + + ";org.springframework.beans.factory.Aware"; + + JavaReadCache readCache = mock(JavaReadCache.class); + when(readCache.readBytes(cacheKey)).thenReturn(serialized.getBytes(StandardCharsets.UTF_8)); + CacheContext cacheContext = mockCacheContext(readCache, mock(JavaWriteCache.class)); + + InputFileScannerContext context = mock(InputFileScannerContext.class); + when(context.getInputFile()).thenReturn(inputFile); + when(context.getCacheContext()).thenReturn(cacheContext); + + assertThat(gatherer.scanWithoutParsing(context)).isTrue(); + + ModuleScannerContext moduleScannerContext = mock(ModuleScannerContext.class); + when(moduleScannerContext.getModuleKey()).thenReturn(""); + gatherer.gatherSpringContextData(moduleScannerContext, model); + + var index = model.getTypeToBeanNamesIndex(); + assertThat(index.getNamesForType("checks.spring.context.ComponentImplementingInterface")) + .containsOnly("componentImplementingInterface"); + assertThat(index.getNamesForType("org.springframework.context.ApplicationContextAware")) + .containsOnly("componentImplementingInterface"); + assertThat(index.getNamesForType("org.springframework.beans.factory.Aware")) + .containsOnly("componentImplementingInterface"); } @Test @@ -583,13 +622,6 @@ void index_gatherer_skipped_when_spring_not_in_classpath() { .isEmpty(); } - @Test - void index_anonymous_class_is_skipped() { - scan("src/test/files/springcontext/SpringBootAppWithAnonymousClass.java"); - - assertThat(model.getTypeToBeanNamesIndex().getNamesForType("")).isEmpty(); - } - private static String beanClassNameFrom(String filePath) { return filePath.substring(filePath.lastIndexOf('/') + 1, filePath.lastIndexOf('.')); }