diff --git a/its/autoscan/src/test/java/org/sonar/java/it/AutoScanTest.java b/its/autoscan/src/test/java/org/sonar/java/it/AutoScanTest.java index bfae131a57a..282efbcc754 100644 --- a/its/autoscan/src/test/java/org/sonar/java/it/AutoScanTest.java +++ b/its/autoscan/src/test/java/org/sonar/java/it/AutoScanTest.java @@ -123,7 +123,7 @@ public void javaCheckTestSources() throws Exception { .setProjectName(PROJECT_NAME) .setProjectVersion("0.1.0-SNAPSHOT") .setSourceEncoding("UTF-8") - .setSourceDirs("aws/src/main/java/,default/src/main/java/,java-17/src/main/java/,spring-3.2/src/main/java/,spring-web-4.0/src/main/java/") + .setSourceDirs("aws/src/main/java/,default/src/main/java/,java-17/src/main/java/,quarkus-arc-3.15/src/main/java/,spring-3.2/src/main/java/,spring-web-4.0/src/main/java/") .setTestDirs("default/src/test/java/,java-17/src/test/java/,test-classpath-reader/src/test/java") .setProperty("sonar.java.source", "26") // common properties diff --git a/its/autoscan/src/test/resources/autoscan/diffs/diff_S9068.json b/its/autoscan/src/test/resources/autoscan/diffs/diff_S9068.json new file mode 100644 index 00000000000..7e42fdc3ebe --- /dev/null +++ b/its/autoscan/src/test/resources/autoscan/diffs/diff_S9068.json @@ -0,0 +1,6 @@ +{ + "ruleKey": "S9068", + "hasTruePositives": false, + "falseNegatives": 3, + "falsePositives": 0 +} diff --git a/java-checks-test-sources/pom.xml b/java-checks-test-sources/pom.xml index 1489eb9debe..226f86cf559 100644 --- a/java-checks-test-sources/pom.xml +++ b/java-checks-test-sources/pom.xml @@ -22,6 +22,7 @@ java-17 spring-3.2 spring-web-4.0 + quarkus-arc-3.15 test-classpath-reader diff --git a/java-checks-test-sources/quarkus-arc-3.15/pom.xml b/java-checks-test-sources/quarkus-arc-3.15/pom.xml new file mode 100644 index 00000000000..1fd497fddbe --- /dev/null +++ b/java-checks-test-sources/quarkus-arc-3.15/pom.xml @@ -0,0 +1,114 @@ + + + 4.0.0 + + + org.sonarsource.java + java-checks-test-sources + 8.37.0-SNAPSHOT + + + quarkus-arc-3.15 + + SonarQube Java :: Checks Test Sources :: Quarkus ArC 3.15 + + + true + true + true + true + + + + + io.quarkus + quarkus-arc + 3.15.1 + provided + + + + + + analyze-tests + + false + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + + + + org.simplify4u.plugins + sign-maven-plugin + + + sign-artifacts + none + + + + + maven-jar-plugin + + + default-jar + none + + + + + maven-source-plugin + + + attach-sources + none + + + + + maven-javadoc-plugin + + + attach-javadocs + none + + + + + maven-install-plugin + + + default-install + none + + + + + com.mycila + license-maven-plugin + + + + + src/main/java/** + src/test/java/** + + + + + + + + + diff --git a/java-checks-test-sources/quarkus-arc-3.15/src/main/java/checks/quarkus/SingletonInsteadOfApplicationScopedCheckSample.java b/java-checks-test-sources/quarkus-arc-3.15/src/main/java/checks/quarkus/SingletonInsteadOfApplicationScopedCheckSample.java new file mode 100644 index 00000000000..5c5869a642e --- /dev/null +++ b/java-checks-test-sources/quarkus-arc-3.15/src/main/java/checks/quarkus/SingletonInsteadOfApplicationScopedCheckSample.java @@ -0,0 +1,103 @@ +package checks.quarkus; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Singleton; + +@Singleton // Noncompliant [[sc=1;ec=11]] +class NoncompliantService { + public String foo() { + return "foo"; + } +} + +class NoncompliantProducerClass { + @Singleton // Noncompliant +//^^^^^^^^^^ + public NoncompliantService produceService() { + return new NoncompliantService(); + } +} + +// Using @Singleton intentionally: low-level config holder, singleton scope required for eager init. +@Singleton +class CompliantWithComment { + private static final String VERSION = "1.0"; + + public String getVersion() { + return VERSION; + } +} + +class CompliantProducerWithComment { + // Singleton scope required: external library expects a non-proxied instance. + @Singleton + public CompliantWithComment produceConfig() { + return new CompliantWithComment(); + } +} + +/** + * Using @Singleton intentionally: this is a low-level infrastructure bean with no interceptor requirements. + * Singleton scope required to avoid proxy overhead measured on this critical path. + */ +@Singleton +class CompliantWithJavadocComment { + public String getConfig() { + return "config"; + } +} + +@Singleton // singleton scope required: this bean is a lightweight config holder with no interception needs +class CompliantWithInlineComment { + public String getConfig() { + return "config"; + } +} + +class CompliantProducerWithInlineComment { + @Singleton // singleton scope required: non-proxied instance expected by external library + public CompliantWithInlineComment produceConfig() { + return new CompliantWithInlineComment(); + } +} + +@Singleton +// singleton scope required: lightweight config holder with no interception needs +class CompliantWithFollowingLineComment { + public String getConfig() { + return "config"; + } +} + +class CompliantProducerWithFollowingLineComment { + @Singleton + // singleton scope required: non-proxied instance expected by external library + public CompliantWithFollowingLineComment produceConfig() { + return new CompliantWithFollowingLineComment(); + } +} + +@Singleton // Noncompliant [[sc=1;ec=11]] +record NoncompliantRecord(String value) {} + +// Singleton scope required: external lib needs a non-proxied instance. +@Singleton +record CompliantRecord(String value) {} + +@ApplicationScoped +class SingletonCheckCompliantApplicationScoped { + public String hello() { + return "hello"; + } +} + +@Dependent +class SingletonCheckCompliantDependent { +} + +class CompliantNoScope { + public String hello() { + return "hello"; + } +} diff --git a/java-checks-test-sources/test-classpath-reader/src/main/java/org/sonar/java/test/classpath/TestClasspathUtils.java b/java-checks-test-sources/test-classpath-reader/src/main/java/org/sonar/java/test/classpath/TestClasspathUtils.java index b07799d8816..00899a8228a 100644 --- a/java-checks-test-sources/test-classpath-reader/src/main/java/org/sonar/java/test/classpath/TestClasspathUtils.java +++ b/java-checks-test-sources/test-classpath-reader/src/main/java/org/sonar/java/test/classpath/TestClasspathUtils.java @@ -48,6 +48,7 @@ public final class TestClasspathUtils { public static final Module JAVA_17_MODULE = new Module("java-checks-test-sources/java-17"); public static final Module SPRING_32_MODULE = new Module("java-checks-test-sources/spring-3.2"); public static final Module SPRING_WEB_40_MODULE = new Module("java-checks-test-sources/spring-web-4.0"); + public static final Module QUARKUS_ARC_315_MODULE = new Module("java-checks-test-sources/quarkus-arc-3.15"); private static final Path testJarsPath = Path.of("java-checks-test-sources/target/test-jars"); public static List getTestJars(List jars) { diff --git a/java-checks/src/main/java/org/sonar/java/checks/quarkus/SingletonInsteadOfApplicationScopedCheck.java b/java-checks/src/main/java/org/sonar/java/checks/quarkus/SingletonInsteadOfApplicationScopedCheck.java new file mode 100644 index 00000000000..a0acf218be5 --- /dev/null +++ b/java-checks/src/main/java/org/sonar/java/checks/quarkus/SingletonInsteadOfApplicationScopedCheck.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.checks.quarkus; + +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; +import org.sonar.check.Rule; +import org.sonar.plugins.java.api.DependencyVersionAware; +import org.sonar.plugins.java.api.IssuableSubscriptionVisitor; +import org.sonar.plugins.java.api.Version; +import org.sonar.plugins.java.api.tree.AnnotationTree; +import org.sonar.plugins.java.api.tree.ClassTree; +import org.sonar.plugins.java.api.tree.MethodTree; +import org.sonar.plugins.java.api.tree.ModifierKeywordTree; +import org.sonar.plugins.java.api.tree.ModifiersTree; +import org.sonar.plugins.java.api.tree.SyntaxToken; +import org.sonar.plugins.java.api.tree.SyntaxTrivia; +import org.sonar.plugins.java.api.tree.Tree; + +@Rule(key = "S9068") +public class SingletonInsteadOfApplicationScopedCheck extends IssuableSubscriptionVisitor implements DependencyVersionAware { + + private static final String JAKARTA_SINGLETON = "jakarta.inject.Singleton"; + private static final String MESSAGE = "Replace \"@Singleton\" by \"@ApplicationScoped\" or add a comment indicating why \"@Singleton\" is necessary."; + + @Override + public boolean isCompatibleWithDependencies(Function> dependencyFinder) { + return dependencyFinder.apply("quarkus-arc").isPresent(); + } + + @Override + public List nodesToVisit() { + return List.of(Tree.Kind.CLASS, Tree.Kind.RECORD, Tree.Kind.METHOD); + } + + @Override + public void visitNode(Tree tree) { + ModifiersTree modifiers; + SyntaxToken declarationToken; + if (tree instanceof ClassTree classTree) { + modifiers = classTree.modifiers(); + declarationToken = classTree.declarationKeyword(); + } else { + MethodTree methodTree = (MethodTree) tree; + modifiers = methodTree.modifiers(); + declarationToken = methodTree.returnType().firstToken(); + } + + modifiers.annotations().stream() + .filter(annotation -> annotation.annotationType().symbolType().is(JAKARTA_SINGLETON)) + .filter(annotation -> !hasJustifyingComment(annotation, modifiers, declarationToken)) + .forEach(annotation -> reportIssue(annotation, MESSAGE)); + } + + private static boolean hasJustifyingComment(AnnotationTree annotation, ModifiersTree modifiers, SyntaxToken declarationToken) { + // Comment appearing before the annotation (trivia on its @ token) + if (containsSingletonComment(annotation.firstToken().trivias())) { + return true; + } + // Comment after the annotation (inline or on the following line): it attaches as trivia to the + // next token in the stream, which may be another annotation's @, a modifier keyword, or the + // declaration keyword / return type. + Stream candidateTokens = Stream.concat( + Stream.concat( + modifiers.annotations().stream() + .map(Tree::firstToken) + .filter(t -> !t.equals(annotation.firstToken())), + modifiers.modifiers().stream() + .map(ModifierKeywordTree::keyword)), + Stream.of(declarationToken)); + return candidateTokens + .flatMap(token -> token.trivias().stream()) + .anyMatch(SingletonInsteadOfApplicationScopedCheck::isSingletonComment); + } + + private static boolean containsSingletonComment(List trivias) { + return trivias.stream().anyMatch(SingletonInsteadOfApplicationScopedCheck::isSingletonComment); + } + + private static boolean isSingletonComment(SyntaxTrivia trivia) { + return trivia.comment().toLowerCase(Locale.ROOT).contains("singleton"); + } +} diff --git a/java-checks/src/test/java/org/sonar/java/checks/quarkus/SingletonInsteadOfApplicationScopedCheckTest.java b/java-checks/src/test/java/org/sonar/java/checks/quarkus/SingletonInsteadOfApplicationScopedCheckTest.java new file mode 100644 index 00000000000..4bdf743b52e --- /dev/null +++ b/java-checks/src/test/java/org/sonar/java/checks/quarkus/SingletonInsteadOfApplicationScopedCheckTest.java @@ -0,0 +1,55 @@ +/* + * 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.checks.quarkus; + +import org.junit.jupiter.api.Test; +import org.sonar.java.checks.verifier.CheckVerifier; + +import static org.sonar.java.checks.verifier.TestUtils.mainCodeSourcesPathInModule; +import static org.sonar.java.test.classpath.TestClasspathUtils.QUARKUS_ARC_315_MODULE; + +class SingletonInsteadOfApplicationScopedCheckTest { + + private static final String SAMPLE = mainCodeSourcesPathInModule(QUARKUS_ARC_315_MODULE, "checks/quarkus/SingletonInsteadOfApplicationScopedCheckSample.java"); + + @Test + void test() { + CheckVerifier.newVerifier() + .onFile(SAMPLE) + .withCheck(new SingletonInsteadOfApplicationScopedCheck()) + .withClassPath(QUARKUS_ARC_315_MODULE.getClassPath()) + .verifyIssues(); + } + + @Test + void testWithoutSemantic() { + CheckVerifier.newVerifier() + .onFile(SAMPLE) + .withCheck(new SingletonInsteadOfApplicationScopedCheck()) + .withoutSemantic() + .verifyNoIssues(); + } + + @Test + void testWithoutQuarkusOnClasspath() { + CheckVerifier.newVerifier() + .onFile(SAMPLE) + .withCheck(new SingletonInsteadOfApplicationScopedCheck()) + .withClassPath(java.util.List.of()) + .verifyNoIssues(); + } +} diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9068.html b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9068.html new file mode 100644 index 00000000000..b3ca8a4ebcc --- /dev/null +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9068.html @@ -0,0 +1,114 @@ +

This rule raises an issue when a CDI bean uses the @Singleton annotation from jakarta.inject instead of +@ApplicationScoped in a Quarkus application.

+

Why is this an issue?

+

In Quarkus applications, both @Singleton (from Jakarta Dependency Injection) and @ApplicationScoped (from Jakarta +Contexts and Dependency Injection) create single-instance beans. However, they behave differently in important ways.

+

@ApplicationScoped beans are full CDI beans with these capabilities:

+
    +
  • Support lazy initialization via client proxies
  • +
  • Can be mocked in tests using QuarkusMock
  • +
  • Can be destroyed and recreated at runtime thanks to proxy-based method delegation
  • +
+

@Singleton beans are simpler but more limited:

+
    +
  • Created eagerly when the bean is injected
  • +
  • Cannot be mocked using QuarkusMock in tests
  • +
+

For most application-level services, @ApplicationScoped is the better choice because it provides the full CDI feature set that Quarkus +applications typically need.

+

When @Singleton might be acceptable

+

@Singleton might have a slightly better performance than @ApplicationScoped because of the absence of client proxy. Thus, +there are specific scenarios where @Singleton may be intentionally chosen:

+
    +
  • Low-level infrastructure beans: Beans that provide basic utilities and don’t require lazy initialization
  • +
  • Performance-critical paths: Beans where the proxy overhead of @ApplicationScoped is measured and found to be + significant (rare, and should be documented)
  • +
  • Third-party integration: When integrating libraries that specifically expect non-proxied instances
  • +
+

If you choose to use @Singleton, document why you need it and confirm that:

+
    +
  • The bean does not need to be mocked in tests, or you use alternative mocking strategies
  • +
  • You understand the limitations and accept them for this specific case
  • +
+

What is the potential impact?

+

Using @Singleton instead of @ApplicationScoped can lead to several issues:

+

Testing difficulties

+

@Singleton beans cannot be mocked using QuarkusMock in tests. This means:

+
    +
  • Integration tests cannot isolate the bean for testing
  • +
  • External dependencies (databases, APIs) cannot be easily stubbed
  • +
  • Tests may become slower and more fragile
  • +
  • Test coverage may be reduced
  • +
+

Reduced flexibility

+

Choosing @Singleton limits your future options:

+
    +
  • Lazy initialization via proxies is unavailable
  • +
+

How to fix it in Quarkus

+

Replace the @Singleton annotation with @ApplicationScoped. Update the import statement to use +jakarta.enterprise.context.ApplicationScoped instead of jakarta.inject.Singleton.

+

Code examples

+

Noncompliant code example

+
+import jakarta.inject.Singleton;
+
+@Singleton // Noncompliant
+public class UserService {
+    public String getUser(Long id) {
+        // implementation
+        return "user";
+    }
+}
+
+

Compliant solution

+
+import jakarta.enterprise.context.ApplicationScoped;
+
+@ApplicationScoped
+public class UserService {
+    public String getUser(Long id) {
+        // implementation
+        return "user";
+    }
+}
+
+

If you have a documented, valid reason to use @Singleton, you can keep it but should add a comment explaining why. This is an +alternative to changing the annotation, though it’s only appropriate in specific cases.

+

Noncompliant code example

+
+import jakarta.inject.Singleton;
+
+@Singleton // Noncompliant
+public class ApplicationConfig {
+    private final String version = "1.0.0";
+
+    public String getVersion() {
+        return version;
+    }
+}
+
+

Compliant solution

+
+import jakarta.inject.Singleton;
+
+// Using @Singleton intentionally: this is a simple configuration holder
+// with no interceptor requirements and no need for testing isolation.
+// It's instantiated eagerly at startup to validate configuration.
+@Singleton
+public class ApplicationConfig {
+    private final String version = "1.0.0";
+
+    public String getVersion() {
+        return version;
+    }
+}
+
+

Resources

+

Documentation

+ + diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9068.json b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9068.json new file mode 100644 index 00000000000..5e9dfbe8d80 --- /dev/null +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S9068.json @@ -0,0 +1,25 @@ +{ + "title": "\"@ApplicationScoped\" should be preferred over \"@Singleton\" in Quarkus applications", + "type": "CODE_SMELL", + "status": "ready", + "remediation": { + "func": "Constant\/Issue", + "constantCost": "5 min" + }, + "tags": [ + "jee", + "injection", + "testing" + ], + "defaultSeverity": "Major", + "ruleSpecification": "RSPEC-9068", + "sqKey": "S9068", + "scope": "All", + "quickfix": "unknown", + "code": { + "impacts": { + "MAINTAINABILITY": "MEDIUM" + }, + "attribute": "MODULAR" + } +} diff --git a/sonar-java-plugin/src/main/resources/profiles/Sonar_way/S9068 b/sonar-java-plugin/src/main/resources/profiles/Sonar_way/S9068 new file mode 100644 index 00000000000..e69de29bb2d