From da6a16aa7b2f4174a70146de8b6c12537a357ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Coet?= Date: Fri, 27 Feb 2026 11:00:17 +0100 Subject: [PATCH 1/4] Implement rule S8471 --- .../MinimizeBoundScopedValuesCheckSample.java | 64 +++++++++++++++ .../MinimizeBoundScopedValuesCheck.java | 79 +++++++++++++++++++ .../MinimizeBoundScopedValuesCheckTest.java | 44 +++++++++++ .../org/sonar/l10n/java/rules/java/S8471.html | 44 +++++++++++ .../org/sonar/l10n/java/rules/java/S8471.json | 24 ++++++ .../java/rules/java/Sonar_way_profile.json | 3 +- 6 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 java-checks-test-sources/default/src/main/java/checks/MinimizeBoundScopedValuesCheckSample.java create mode 100644 java-checks/src/main/java/org/sonar/java/checks/MinimizeBoundScopedValuesCheck.java create mode 100644 java-checks/src/test/java/org/sonar/java/checks/MinimizeBoundScopedValuesCheckTest.java create mode 100644 sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S8471.html create mode 100644 sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S8471.json diff --git a/java-checks-test-sources/default/src/main/java/checks/MinimizeBoundScopedValuesCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/MinimizeBoundScopedValuesCheckSample.java new file mode 100644 index 00000000000..b724f9a8886 --- /dev/null +++ b/java-checks-test-sources/default/src/main/java/checks/MinimizeBoundScopedValuesCheckSample.java @@ -0,0 +1,64 @@ +package checks; + +public class MinimizeBoundScopedValuesCheckSample { + + private static final ScopedValue USER_NAME = ScopedValue.newInstance(); + private static final ScopedValue USER_ID = ScopedValue.newInstance(); + private static final ScopedValue USER_EMAIL = ScopedValue.newInstance(); + private static final ScopedValue USER_ROLE = ScopedValue.newInstance(); + + public void processFour() { + ScopedValue.where(USER_NAME, "John") // Noncompliant {{Consider grouping the 4 scoped values bound in this chain of method calls into a record class to maintain good performance.}} + .where(USER_ID, "123") + .where(USER_EMAIL, "john@example.com") + .where(USER_ROLE, "admin") + .run(this::doWork); + } + + public void processThree() { + ScopedValue.where(USER_NAME, "John") // Noncompliant {{Consider grouping the 3 scoped values bound in this chain of method calls into a record class to maintain good performance.}} + .where(USER_ID, "123") + .where(USER_EMAIL, "john@example.com") + .run(this::doWork2); + } + + public void processThreeSeparated() { + ScopedValue carrier = ScopedValue.newInstance(); + ScopedValue.where(carrier, ScopedValue.where(USER_NAME, "John")).run(() -> + ScopedValue.where(USER_ID, "123") // Noncompliant {{Consider grouping the 3 scoped values bound in this chain of method calls into a record class to maintain good performance.}} + .call(carrier::get) + .where(USER_EMAIL, "john@example.com") + .where(USER_ROLE, "admin") + .run(this::doWork)); + } + + public void processThreeFromCarrier() { + ScopedValue carrier = ScopedValue.newInstance(); + ScopedValue.where(carrier, ScopedValue.where(USER_NAME, "John")).run(() -> + carrier.get() // Noncompliant {{Consider grouping the 3 scoped values bound in this chain of method calls into a record class to maintain good performance.}} + .where(USER_ID, "123") + .where(USER_NAME, "John") + .where(USER_EMAIL, "john@mail.com") + .run(this::doWork) + ); + } + + public void processTwoOrLess() { + ScopedValue.where(USER_NAME, "John") // Compliant + .where(USER_ID, "123") + .run(this::doWork2); + + ScopedValue.where(USER_EMAIL, "john@email.org").run(() -> IO.print(USER_EMAIL.get())); // Compliant + } + + private void doWork() { + String importantMessage = String.format("User %s, ID %s, email %s, role %s", USER_NAME.get(), USER_ID.get(), USER_EMAIL.get(), USER_ROLE.get()); + IO.println(importantMessage); + } + + private void doWork2() { + String importantMessage = String.format("User %s, ID %s", USER_NAME.get(), USER_ID.get()); + IO.println(importantMessage); + } + +} diff --git a/java-checks/src/main/java/org/sonar/java/checks/MinimizeBoundScopedValuesCheck.java b/java-checks/src/main/java/org/sonar/java/checks/MinimizeBoundScopedValuesCheck.java new file mode 100644 index 00000000000..3973a56686b --- /dev/null +++ b/java-checks/src/main/java/org/sonar/java/checks/MinimizeBoundScopedValuesCheck.java @@ -0,0 +1,79 @@ +/* + * SonarQube Java + * Copyright (C) 2012-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA. + * + * 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; + +import java.util.HashSet; +import java.util.Set; +import org.sonar.api.internal.apachecommons.lang3.tuple.Pair; +import org.sonar.check.Rule; +import org.sonar.java.checks.methods.AbstractMethodDetection; +import org.sonar.plugins.java.api.JavaVersion; +import org.sonar.plugins.java.api.JavaVersionAwareVisitor; +import org.sonar.plugins.java.api.semantic.MethodMatchers; +import org.sonar.plugins.java.api.tree.MemberSelectExpressionTree; +import org.sonar.plugins.java.api.tree.MethodInvocationTree; +import org.sonar.plugins.java.api.tree.Tree; + +@Rule(key = "S8471") +public class MinimizeBoundScopedValuesCheck extends AbstractMethodDetection implements JavaVersionAwareVisitor { + + private static final int THRESHOLD = 3; + private static final String MESSAGE = "Consider grouping the %d scoped values bound in this chain of method calls into a record class to maintain good performance."; + + private final Set visitedMethodInvocations = new HashSet<>(); + + private static final MethodMatchers WHERE_MATCHER = MethodMatchers.create() + .ofTypes("java.lang.ScopedValue", "java.lang.ScopedValue$Carrier") + .names("where") + .withAnyParameters() + .build(); + + @Override + public boolean isCompatibleWithJavaVersion(JavaVersion version) { + return version.isJava25Compatible(); + } + + @Override + protected MethodMatchers getMethodInvocationMatchers() { + return WHERE_MATCHER; + } + + @Override + protected void onMethodInvocationFound(MethodInvocationTree methodInvocation) { + Pair whereCountAndChainStart = countWhereChain(methodInvocation); + int whereCount = whereCountAndChainStart.getLeft(); + Tree chainStart = whereCountAndChainStart.getRight(); + if (whereCount >= THRESHOLD) { + reportIssue(chainStart, methodInvocation, String.format(MESSAGE, whereCount)); + } + } + + private Pair countWhereChain(MethodInvocationTree methodInvocation) { + int count = 0; + while (visitedMethodInvocations.add(methodInvocation)) { + if (WHERE_MATCHER.matches(methodInvocation)) { + count++; + } + if (methodInvocation.methodSelect() instanceof MemberSelectExpressionTree memberSelect && + memberSelect.expression() instanceof MethodInvocationTree previousInvocation) { + methodInvocation = previousInvocation; + } + } + return Pair.of(count, methodInvocation); + } + +} diff --git a/java-checks/src/test/java/org/sonar/java/checks/MinimizeBoundScopedValuesCheckTest.java b/java-checks/src/test/java/org/sonar/java/checks/MinimizeBoundScopedValuesCheckTest.java new file mode 100644 index 00000000000..26c7dec69ef --- /dev/null +++ b/java-checks/src/test/java/org/sonar/java/checks/MinimizeBoundScopedValuesCheckTest.java @@ -0,0 +1,44 @@ +/* + * SonarQube Java + * Copyright (C) 2012-2025 SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA. + * + * 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; + +import org.junit.jupiter.api.Test; +import org.sonar.java.checks.verifier.CheckVerifier; + +import static org.sonar.java.checks.verifier.TestUtils.mainCodeSourcesPath; + +class MinimizeBoundScopedValuesCheckTest { + + @Test + void test() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath("checks/MinimizeBoundScopedValuesCheckSample.java")) + .withCheck(new MinimizeBoundScopedValuesCheck()) + .withJavaVersion(25) + .verifyIssues(); + } + + @Test + void test_24() { + CheckVerifier.newVerifier() + .onFile(mainCodeSourcesPath("checks/MinimizeBoundScopedValuesCheckSample.java")) + .withCheck(new MinimizeBoundScopedValuesCheck()) + .withJavaVersion(24) + .verifyNoIssues(); + } + +} diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S8471.html b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S8471.html new file mode 100644 index 00000000000..690fd033f73 --- /dev/null +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S8471.html @@ -0,0 +1,44 @@ +

Java 25 introduced scoped values as a lightweight alternative to thread-local variables. They provide a way to safely and efficiently pass data to +faraway methods without using method parameters. To maintain good performance, however, the number of scoped values bound at the same time should +remain low.

+

Why is this an issue?

+

Invocations of ScopedValue.get() typically need to search through enclosing scopes to find the innermost binding and then cache the +result of this search for subsequent accesses. In the presence of too many scoped values, this cache will have a low hit rate, which will cause poor +performance.

+

How to fix it

+

To maintain good performance, minimize the number of bound scoped values in use. When multiple values need to be passed, create a record class to +hold those values and bind a single ScopedValue to an instance of that record, rather than binding multiple separate instances.

+

Code examples

+

Noncompliant code example

+
+private static final ScopedValue<String> USER_NAME = ScopedValue.newInstance();
+private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
+private static final ScopedValue<String> USER_EMAIL = ScopedValue.newInstance();
+private static final ScopedValue<String> USER_ROLE = ScopedValue.newInstance();
+
+public void process() {
+    ScopedValue.where(USER_NAME, "John")
+        .where(USER_ID, "123")
+        .where(USER_EMAIL, "john@example.com")
+        .where(USER_ROLE, "admin")
+        .run(() -> doWork());
+}
+
+

Compliant solution

+
+record UserContext(String name, String id, String email, String role) {}
+
+private static final ScopedValue<UserContext> USER_CONTEXT = ScopedValue.newInstance();
+
+public void process() {
+    UserContext context = new UserContext("John", "123", "john@example.com", "admin");
+    ScopedValue.where(USER_CONTEXT, context).run(() -> doWork());
+}
+
+

Resources

+

Documentation

+ + diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S8471.json b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S8471.json new file mode 100644 index 00000000000..77549166d1b --- /dev/null +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/S8471.json @@ -0,0 +1,24 @@ +{ + "title": "Minimize the number of bound ScopedValues for performance", + "type": "CODE_SMELL", + "status": "ready", + "remediation": { + "func": "Constant\/Issue", + "constantCost": "15min" + }, + "tags": [ + "java25" + ], + "defaultSeverity": "Minor", + "ruleSpecification": "RSPEC-8471", + "sqKey": "S8471", + "scope": "All", + "quickfix": "unknown", + "code": { + "impacts": { + "MAINTAINABILITY": "LOW", + "RELIABILITY": "LOW" + }, + "attribute": "EFFICIENT" + } +} diff --git a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/Sonar_way_profile.json b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/Sonar_way_profile.json index 4feda171218..87327aa22a7 100644 --- a/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/Sonar_way_profile.json +++ b/sonar-java-plugin/src/main/resources/org/sonar/l10n/java/rules/java/Sonar_way_profile.json @@ -525,6 +525,7 @@ "S8445", "S8446", "S8447", - "S8450" + "S8450", + "S8471" ] } From 928585854461633d4d867534ba097ef93ebceee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Coet?= Date: Fri, 27 Feb 2026 14:46:07 +0100 Subject: [PATCH 2/4] Add a test to cover ScopedValue.Carrier instances stored in variables --- .../java/checks/MinimizeBoundScopedValuesCheckSample.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/java-checks-test-sources/default/src/main/java/checks/MinimizeBoundScopedValuesCheckSample.java b/java-checks-test-sources/default/src/main/java/checks/MinimizeBoundScopedValuesCheckSample.java index b724f9a8886..bf5b358136b 100644 --- a/java-checks-test-sources/default/src/main/java/checks/MinimizeBoundScopedValuesCheckSample.java +++ b/java-checks-test-sources/default/src/main/java/checks/MinimizeBoundScopedValuesCheckSample.java @@ -41,6 +41,12 @@ public void processThreeFromCarrier() { .where(USER_EMAIL, "john@mail.com") .run(this::doWork) ); + + var c = carrier.get(); + c.where(USER_ID, "123") // Noncompliant {{Consider grouping the 3 scoped values bound in this chain of method calls into a record class to maintain good performance.}} + .where(USER_NAME, "John") + .where(USER_EMAIL, "john@mail.com") + .run(this::doWork); } public void processTwoOrLess() { From 24723efb2f218e31521801447d71a320bd1672f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Coet?= Date: Fri, 27 Feb 2026 15:15:10 +0100 Subject: [PATCH 3/4] Fix invalid import --- .../org/sonar/java/checks/MinimizeBoundScopedValuesCheck.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/java-checks/src/main/java/org/sonar/java/checks/MinimizeBoundScopedValuesCheck.java b/java-checks/src/main/java/org/sonar/java/checks/MinimizeBoundScopedValuesCheck.java index 3973a56686b..cd6b0c7bf5e 100644 --- a/java-checks/src/main/java/org/sonar/java/checks/MinimizeBoundScopedValuesCheck.java +++ b/java-checks/src/main/java/org/sonar/java/checks/MinimizeBoundScopedValuesCheck.java @@ -18,7 +18,7 @@ import java.util.HashSet; import java.util.Set; -import org.sonar.api.internal.apachecommons.lang3.tuple.Pair; +import org.apache.commons.lang3.tuple.Pair; import org.sonar.check.Rule; import org.sonar.java.checks.methods.AbstractMethodDetection; import org.sonar.plugins.java.api.JavaVersion; From d4892ec0e78ed34d3874430691eb90e39576ebee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Coet?= Date: Fri, 27 Feb 2026 15:30:30 +0100 Subject: [PATCH 4/4] Fix the autoscan tests --- .../src/test/resources/autoscan/autoscan-diff-by-rules.json | 6 ++++++ .../src/test/resources/autoscan/diffs/diff_S8471.json | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100644 its/autoscan/src/test/resources/autoscan/diffs/diff_S8471.json diff --git a/its/autoscan/src/test/resources/autoscan/autoscan-diff-by-rules.json b/its/autoscan/src/test/resources/autoscan/autoscan-diff-by-rules.json index 5ffa4920834..8a2c4614c2e 100644 --- a/its/autoscan/src/test/resources/autoscan/autoscan-diff-by-rules.json +++ b/its/autoscan/src/test/resources/autoscan/autoscan-diff-by-rules.json @@ -2896,5 +2896,11 @@ "hasTruePositives": false, "falseNegatives": 0, "falsePositives": 0 + }, + { + "ruleKey": "S8471", + "hasTruePositives": true, + "falseNegatives": 0, + "falsePositives": 0 } ] diff --git a/its/autoscan/src/test/resources/autoscan/diffs/diff_S8471.json b/its/autoscan/src/test/resources/autoscan/diffs/diff_S8471.json new file mode 100644 index 00000000000..b7d930408be --- /dev/null +++ b/its/autoscan/src/test/resources/autoscan/diffs/diff_S8471.json @@ -0,0 +1,6 @@ +{ + "ruleKey": "S8471", + "hasTruePositives": true, + "falseNegatives": 0, + "falsePositives": 0 +}