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..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
@@ -28,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;
@@ -38,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;
@@ -60,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 {
@@ -72,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";
@@ -88,7 +96,8 @@ private record BeanData(
InputFile inputFile,
AnalyzerMessage.TextSpan textSpan,
boolean isPrimary,
- Map> dependingBeans) {
+ Map> dependingBeans,
+ Set typeHierarchy) {
}
@Override
@@ -117,13 +126,14 @@ public void visitNode(Tree tree) {
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);
@@ -167,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,
@@ -175,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
@@ -190,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());
+ }
}
}
@@ -247,7 +262,10 @@ 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[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) {
@@ -274,34 +292,38 @@ private static String defaultBeanName(String 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()
+ List beanNames = Optional.ofNullable(attrs)
+ .map(list -> list.stream()
.filter(v -> VALUE_ATTRIBUTE.equals(v.name()) || "name".equals(v.name()))
- .map(v -> {
+ .flatMap(v -> {
Object val = v.value();
if (val instanceof Object[] arr && arr.length > 0) {
- return (String) arr[0];
+ return Arrays.stream(arr).filter(String.class::isInstance).map(String.class::cast);
}
- return val instanceof String s ? s : null;
+ return Stream.empty();
})
- .filter(s -> s != null && !s.isBlank())
- .findFirst())
- .orElseGet(() -> method.simpleName().name());
+ .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) {
@@ -358,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/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/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/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/BeanDefinitionGathererTest.java b/java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java
index a890b5bee19..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
@@ -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;
@@ -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
@@ -202,13 +203,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");
@@ -334,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
@@ -342,7 +342,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));
@@ -362,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
@@ -462,7 +464,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));
@@ -496,6 +499,133 @@ 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_full_type_hierarchy() {
+ 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");
+ 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
+ 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();
+ }
+
+ 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/utils/SpringUtilsTest.java b/java-frontend/src/test/java/org/sonar/java/utils/SpringUtilsTest.java
index e326a72c7c7..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
@@ -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,105 @@ 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();
+ }
+
}