diff --git a/src/main/java/org/openrewrite/java/migrate/search/threadlocal/AbstractFindThreadLocals.java b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/AbstractFindThreadLocals.java
new file mode 100644
index 0000000000..0bbbfdeda2
--- /dev/null
+++ b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/AbstractFindThreadLocals.java
@@ -0,0 +1,408 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.search.threadlocal;
+
+import lombok.Getter;
+import lombok.Value;
+import org.jspecify.annotations.Nullable;
+import org.openrewrite.ExecutionContext;
+import org.openrewrite.ScanningRecipe;
+import org.openrewrite.SourceFile;
+import org.openrewrite.TreeVisitor;
+import org.openrewrite.java.JavaIsoVisitor;
+import org.openrewrite.java.MethodMatcher;
+import org.openrewrite.java.migrate.table.ThreadLocalTable;
+import org.openrewrite.java.search.UsesType;
+import org.openrewrite.java.tree.Expression;
+import org.openrewrite.java.tree.J;
+import org.openrewrite.java.tree.JavaType;
+import org.openrewrite.java.tree.TypeUtils;
+import org.openrewrite.marker.SearchResult;
+
+import java.nio.file.Path;
+import java.util.*;
+
+import static org.openrewrite.Preconditions.check;
+import static org.openrewrite.Preconditions.or;
+
+
+public abstract class AbstractFindThreadLocals extends ScanningRecipe {
+
+ protected static final String THREAD_LOCAL_FQN = "java.lang.ThreadLocal";
+ protected static final String INHERITED_THREAD_LOCAL_FQN = "java.lang.InheritableThreadLocal";
+
+ private static final MethodMatcher THREAD_LOCAL_SET = new MethodMatcher(THREAD_LOCAL_FQN + " set(..)");
+ private static final MethodMatcher THREAD_LOCAL_REMOVE = new MethodMatcher(THREAD_LOCAL_FQN + " remove()");
+ private static final MethodMatcher INHERITABLE_THREAD_LOCAL_SET = new MethodMatcher(INHERITED_THREAD_LOCAL_FQN + " set(..)");
+ private static final MethodMatcher INHERITABLE_THREAD_LOCAL_REMOVE = new MethodMatcher(INHERITED_THREAD_LOCAL_FQN + " remove()");
+
+ transient ThreadLocalTable dataTable = new ThreadLocalTable(this);
+
+ @Value
+ public static class ThreadLocalAccumulator {
+ Map threadLocals = new HashMap<>();
+
+ public void recordDeclaration(String fqn, Path sourcePath, boolean isPrivate, boolean isStatic, boolean isFinal) {
+ threadLocals.computeIfAbsent(fqn, k -> new ThreadLocalInfo())
+ .setDeclaration(sourcePath, isPrivate, isStatic, isFinal);
+ }
+
+ public void recordMutation(String fqn, Path sourcePath, boolean isInitContext) {
+ ThreadLocalInfo info = threadLocals.computeIfAbsent(fqn, k -> new ThreadLocalInfo());
+ if (isInitContext) {
+ info.addInitMutation(sourcePath);
+ } else {
+ info.addRegularMutation(sourcePath);
+ }
+ }
+
+ public @Nullable ThreadLocalInfo getInfo(String fqn) {
+ return threadLocals.get(fqn);
+ }
+
+ public boolean hasDeclarations() {
+ return threadLocals.values().stream().anyMatch(ThreadLocalInfo::isDeclared);
+ }
+ }
+
+ public static class ThreadLocalInfo {
+ private @Nullable Path declarationPath;
+ @Getter
+ private boolean isPrivate;
+ @Getter
+ private boolean isStatic;
+ @Getter
+ private boolean isFinal;
+ @Getter
+ private boolean declared;
+ private final Set initMutationPaths = new HashSet<>();
+ private final Set regularMutationPaths = new HashSet<>();
+
+ void setDeclaration(Path path, boolean priv, boolean stat, boolean fin) {
+ this.declarationPath = path;
+ this.isPrivate = priv;
+ this.isStatic = stat;
+ this.isFinal = fin;
+ this.declared = true;
+ }
+
+ /**
+ * Records a mutation from an initialization context (constructor/static initializer).
+ */
+ void addInitMutation(Path path) {
+ initMutationPaths.add(path);
+ }
+
+ /**
+ * Records a regular (non-initialization) mutation.
+ */
+ void addRegularMutation(Path path) {
+ regularMutationPaths.add(path);
+ }
+
+ /**
+ * Checks if there are no mutations (both init and regular).
+ */
+ public boolean hasNoMutation() {
+ return initMutationPaths.isEmpty() && regularMutationPaths.isEmpty();
+ }
+
+ /**
+ * Checks if there are only mutations from initialization contexts (constructors/static initializers).
+ */
+ public boolean hasOnlyInitMutations() {
+ return !initMutationPaths.isEmpty() && regularMutationPaths.isEmpty();
+ }
+
+ /**
+ * Checks if there are any mutations (both init and regular) from files other than the declaration file.
+ */
+ public boolean hasExternalMutations() {
+ if (!declared || declarationPath == null) {
+ return true; // Conservative
+ }
+
+ // Check if any mutation is from a different file
+ return initMutationPaths.stream().anyMatch(p -> !p.equals(declarationPath)) ||
+ regularMutationPaths.stream().anyMatch(p -> !p.equals(declarationPath));
+ }
+
+ /**
+ * Checks if all mutations (both init and regular) are from the same file as the declaration.
+ */
+ public boolean isOnlyLocallyMutated() {
+ if (!declared || declarationPath == null) {
+ return false;
+ }
+
+ // All mutations must be from the same file as declaration
+ return initMutationPaths.stream().allMatch(p -> p.equals(declarationPath)) &&
+ regularMutationPaths.stream().allMatch(p -> p.equals(declarationPath));
+ }
+ }
+
+ @Override
+ public ThreadLocalAccumulator getInitialValue(ExecutionContext ctx) {
+ return new ThreadLocalAccumulator();
+ }
+
+ @Override
+ public TreeVisitor, ExecutionContext> getScanner(ThreadLocalAccumulator acc) {
+ return check(
+ or(new UsesType<>(THREAD_LOCAL_FQN, true),
+ new UsesType<>(INHERITED_THREAD_LOCAL_FQN, true)),
+ new JavaIsoVisitor() {
+ @Override
+ public J.VariableDeclarations.NamedVariable visitVariable(
+ J.VariableDeclarations.NamedVariable variable, ExecutionContext ctx) {
+ variable = super.visitVariable(variable, ctx);
+
+ // Early return for non-ThreadLocal types
+ if (!isThreadLocalType(variable.getType())) {
+ return variable;
+ }
+
+ // Early return for local variables (not fields)
+ J.MethodDeclaration enclosingMethod = getCursor().firstEnclosing(J.MethodDeclaration.class);
+ if (enclosingMethod != null) {
+ return variable;
+ }
+
+ // Early return if not in a class
+ J.ClassDeclaration classDecl = getCursor().firstEnclosing(J.ClassDeclaration.class);
+ if (classDecl == null) {
+ return variable;
+ }
+
+ // Early return if we can't find the variable declarations
+ J.VariableDeclarations variableDecls = getCursor().firstEnclosing(J.VariableDeclarations.class);
+ if (variableDecls == null) {
+ return variable;
+ }
+
+ // Process ThreadLocal field declaration
+ JavaType.@Nullable FullyQualified classType = classDecl.getType();
+ String className = classType != null ? classType.getFullyQualifiedName() : "UnknownClass";
+ String fqn = className + "." + variable.getName().getSimpleName();
+
+ boolean isPrivate = variableDecls.hasModifier(J.Modifier.Type.Private);
+ boolean isStatic = variableDecls.hasModifier(J.Modifier.Type.Static);
+ boolean isFinal = variableDecls.hasModifier(J.Modifier.Type.Final);
+ Path sourcePath = getCursor().firstEnclosingOrThrow(SourceFile.class).getSourcePath();
+
+ acc.recordDeclaration(fqn, sourcePath, isPrivate, isStatic, isFinal);
+ return variable;
+ }
+
+ @Override
+ public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) {
+ method = super.visitMethodInvocation(method, ctx);
+
+ // Early return if not a ThreadLocal mutation method
+ if (!THREAD_LOCAL_SET.matches(method) && !THREAD_LOCAL_REMOVE.matches(method) &&
+ !INHERITABLE_THREAD_LOCAL_SET.matches(method) && !INHERITABLE_THREAD_LOCAL_REMOVE.matches(method)) {
+ return method;
+ }
+
+ String fqn = getFieldFullyQualifiedName(method.getSelect());
+ if (fqn == null) {
+ return method;
+ }
+
+ Path sourcePath = getCursor().firstEnclosingOrThrow(SourceFile.class).getSourcePath();
+ boolean isInitContext = isInInitializationContext();
+ acc.recordMutation(fqn, sourcePath, isInitContext);
+ return method;
+ }
+
+ @Override
+ public J.Assignment visitAssignment(J.Assignment assignment, ExecutionContext ctx) {
+ assignment = super.visitAssignment(assignment, ctx);
+
+ // Early return if not a ThreadLocal field access
+ if (!isThreadLocalFieldAccess(assignment.getVariable())) {
+ return assignment;
+ }
+
+ String fqn = getFieldFullyQualifiedName(assignment.getVariable());
+ if (fqn == null) {
+ return assignment;
+ }
+
+ Path sourcePath = getCursor().firstEnclosingOrThrow(SourceFile.class).getSourcePath();
+ boolean isInitContext = isInInitializationContext();
+ acc.recordMutation(fqn, sourcePath, isInitContext);
+ return assignment;
+ }
+
+ private boolean isInInitializationContext() {
+ J.MethodDeclaration methodDecl = getCursor().firstEnclosing(J.MethodDeclaration.class);
+
+ if (methodDecl == null) {
+ // Check if we're in a static initializer block
+ return getCursor().getPathAsStream()
+ .filter(J.Block.class::isInstance)
+ .map(J.Block.class::cast)
+ .anyMatch(J.Block::isStatic);
+ }
+
+ // Check if it's a constructor
+ return methodDecl.isConstructor();
+ }
+
+ private boolean isThreadLocalFieldAccess(Expression expression) {
+ if (expression instanceof J.Identifier) {
+ return isThreadLocalType(expression.getType());
+ }
+ if (expression instanceof J.FieldAccess) {
+ return isThreadLocalType(expression.getType());
+ }
+ return false;
+ }
+
+ private @Nullable String getFieldFullyQualifiedName(@Nullable Expression expression) {
+ if (expression == null) {
+ return null;
+ }
+
+ JavaType.@Nullable Variable varType = null;
+ if (expression instanceof J.Identifier) {
+ varType = ((J.Identifier) expression).getFieldType();
+ } else if (expression instanceof J.FieldAccess) {
+ varType = ((J.FieldAccess) expression).getName().getFieldType();
+ }
+
+ if (varType == null) {
+ return null;
+ }
+
+ JavaType owner = varType.getOwner();
+ if (!(owner instanceof JavaType.FullyQualified)) {
+ return null;
+ }
+
+ return ((JavaType.FullyQualified) owner).getFullyQualifiedName() + "." + varType.getName();
+ }
+
+ });
+ }
+
+ @Override
+ public TreeVisitor, ExecutionContext> getVisitor(ThreadLocalAccumulator acc) {
+ return check(acc.hasDeclarations(),
+ new JavaIsoVisitor() {
+
+
+ @Override
+ public J.VariableDeclarations visitVariableDeclarations(J.VariableDeclarations multiVariable, ExecutionContext ctx) {
+ multiVariable = super.visitVariableDeclarations(multiVariable, ctx);
+
+ J.ClassDeclaration classDecl = getCursor().firstEnclosing(J.ClassDeclaration.class);
+ if(classDecl == null) {
+ return multiVariable;
+ }
+
+ for (J.VariableDeclarations.NamedVariable variable : multiVariable.getVariables()) {
+ if (isThreadLocalType(variable.getType())) {
+ String className = classDecl.getType() != null ?
+ classDecl.getType().getFullyQualifiedName() : "UnknownClass";
+ String fieldName = variable.getName().getSimpleName();
+ String fqn = className + "." + fieldName;
+
+ ThreadLocalInfo info = acc.getInfo(fqn);
+ if (info != null && shouldMarkThreadLocal(info)) {
+ String message = getMessage(info);
+ String mutationType = getMutationType(info);
+
+ dataTable.insertRow(ctx, new ThreadLocalTable.Row(
+ getCursor().firstEnclosingOrThrow(SourceFile.class).getSourcePath().toString(),
+ className,
+ fieldName,
+ getAccessModifier(multiVariable),
+ getFieldModifiers(multiVariable),
+ mutationType,
+ message
+ ));
+
+ return SearchResult.found(multiVariable, message);
+ }
+ }
+ }
+
+ return multiVariable;
+ }
+
+ private String getAccessModifier(J.VariableDeclarations variableDecls) {
+ if (variableDecls.hasModifier(J.Modifier.Type.Private)) {
+ return "private";
+ }
+ if (variableDecls.hasModifier(J.Modifier.Type.Protected)) {
+ return "protected";
+ }
+ if (variableDecls.hasModifier(J.Modifier.Type.Public)) {
+ return "public";
+ }
+ return "package-private";
+ }
+
+ private String getFieldModifiers(J.VariableDeclarations variableDecls) {
+ List mods = new ArrayList<>();
+ if (variableDecls.hasModifier(J.Modifier.Type.Static)) {
+ mods.add("static");
+ }
+ if (variableDecls.hasModifier(J.Modifier.Type.Final)) {
+ mods.add("final");
+ }
+ return String.join(" ", mods);
+ }
+
+ });
+ }
+
+ /**
+ * Determines whether a ThreadLocal should be marked based on its usage info.
+ * Implementations should define the criteria for marking.
+ * It is used to decide if a ThreadLocal variable should be highlighted in the results.
+ * If an expected ThreadLocal instance is missing from the results, consider adjusting this method.
+ *
+ * @param info The ThreadLocalInfo containing usage details.
+ * @return true if the ThreadLocal should be marked, false otherwise.
+ */
+ protected abstract boolean shouldMarkThreadLocal(ThreadLocalInfo info);
+ /**
+ * Generates a descriptive message about the ThreadLocal's usage pattern.
+ * Implementations should provide context-specific messages.
+ * It is used to receive the Markers message and the Data Tables detailed message.
+ *
+ * @param info The ThreadLocalInfo containing usage details.
+ * @return A string message describing the ThreadLocal's usage.
+ */
+ protected abstract String getMessage(ThreadLocalInfo info);
+ /**
+ * Determines the mutation type of the ThreadLocal based on its usage info.
+ * Implementations should define the mutation categories.
+ * It is used to populate the Data Tables human-readable mutation type column.
+ *
+ * @param info The ThreadLocalInfo containing usage details.
+ * @return A string representing the mutation type.
+ */
+ protected abstract String getMutationType(ThreadLocalInfo info);
+
+ protected static boolean isThreadLocalType(@Nullable JavaType type) {
+ return TypeUtils.isOfClassType(type, THREAD_LOCAL_FQN) ||
+ TypeUtils.isOfClassType(type, INHERITED_THREAD_LOCAL_FQN);
+ }
+}
diff --git a/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocals.java b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocals.java
new file mode 100644
index 0000000000..728040176b
--- /dev/null
+++ b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocals.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.search.threadlocal;
+
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class FindNeverMutatedThreadLocals extends AbstractFindThreadLocals {
+
+ @Override
+ public String getDisplayName() {
+ return "Find ThreadLocal variables that are never mutated";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Find `ThreadLocal` variables that are never mutated after initialization. " +
+ "These are prime candidates for migration to `ScopedValue` in Java 25+ as they are effectively immutable. " +
+ "The recipe identifies `ThreadLocal` variables that are only initialized but never reassigned or modified through `set()` or `remove()` methods.";
+ }
+
+ @Override
+ public Set getTags() {
+ return new HashSet<>(Arrays.asList("java25", "threadlocal", "scopedvalue", "migration"));
+ }
+
+ @Override
+ protected boolean shouldMarkThreadLocal(ThreadLocalInfo info) {
+ // Mark ThreadLocals that have no mutations at all
+ return info.hasNoMutation();
+ }
+
+ @Override
+ protected String getMessage(ThreadLocalInfo info) {
+ return "ThreadLocal is never mutated and could be replaced with ScopedValue";
+ }
+
+ @Override
+ protected String getMutationType(ThreadLocalInfo info) {
+ return "Never mutated";
+ }
+}
diff --git a/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutableFromOutside.java b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutableFromOutside.java
new file mode 100644
index 0000000000..b6c0909640
--- /dev/null
+++ b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutableFromOutside.java
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.search.threadlocal;
+
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class FindThreadLocalsMutableFromOutside extends AbstractFindThreadLocals {
+
+ @Override
+ public String getDisplayName() {
+ return "Find ThreadLocal variables mutable from outside their defining class";
+ }
+
+ @Override
+ public String getDescription() {
+ //language=markdown
+ return "Find `ThreadLocal` variables that can be mutated from outside their defining class. " +
+ "These ThreadLocals have the highest risk as they can be modified by any code with access to them. " +
+ "This includes non-private ThreadLocals or those mutated from other classes in the codebase.";
+ }
+
+ @Override
+ public Set getTags() {
+ return new HashSet<>(Arrays.asList("java25", "threadlocal", "scopedvalue", "migration", "security"));
+ }
+
+ @Override
+ protected boolean shouldMarkThreadLocal(ThreadLocalInfo info) {
+ // Mark ThreadLocals that are either:
+ // 1. Actually mutated from outside their defining class
+ // 2. Non-private (and thus potentially mutable from outside)
+ return info.hasExternalMutations() || !info.isPrivate();
+ }
+
+ @Override
+ protected String getMessage(ThreadLocalInfo info) {
+ if (info.hasExternalMutations()) {
+ return "ThreadLocal is mutated from outside its defining class";
+ }
+
+ // Non-private but not currently mutated externally
+ String access = info.isStatic() ? "static " : "";
+ return "ThreadLocal is " + access + "non-private and can potentially be mutated from outside";
+ }
+
+ @Override
+ protected String getMutationType(ThreadLocalInfo info) {
+ if (info.hasExternalMutations()) {
+ return "Mutated externally";
+ }
+
+ return "Potentially mutable";
+ }
+}
diff --git a/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutatedOnlyInDefiningScope.java b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutatedOnlyInDefiningScope.java
new file mode 100644
index 0000000000..844b5c88da
--- /dev/null
+++ b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutatedOnlyInDefiningScope.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.search.threadlocal;
+
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+@Value
+@EqualsAndHashCode(callSuper = false)
+public class FindThreadLocalsMutatedOnlyInDefiningScope extends AbstractFindThreadLocals {
+
+ @Override
+ public String getDisplayName() {
+ return "Find ThreadLocal variables mutated only in their defining scope";
+ }
+
+ @Override
+ public String getDescription() {
+ //language=markdown
+ return "Find `ThreadLocal` variables that are only mutated within their defining class or initialization context (constructor/static initializer). " +
+ "These may be candidates for refactoring as they have limited mutation scope. " +
+ "The recipe identifies `ThreadLocal` variables that are only modified during initialization or within their declaring class.";
+ }
+
+ @Override
+ public Set getTags() {
+ return new HashSet<>(Arrays.asList("java25", "threadlocal", "scopedvalue", "migration"));
+ }
+
+ @Override
+ protected boolean shouldMarkThreadLocal(ThreadLocalInfo info) {
+ if (info.hasNoMutation()) {
+ return false;
+ }
+ if (!info.isPrivate()) {
+ return false;
+ }
+ return info.isOnlyLocallyMutated();
+ }
+
+ @Override
+ protected String getMessage(ThreadLocalInfo info) {
+ if (info.hasOnlyInitMutations()) {
+ return "ThreadLocal is only mutated during initialization (constructor/static initializer)";
+ }
+
+ return "ThreadLocal is only mutated within its defining class";
+ }
+
+ @Override
+ protected String getMutationType(ThreadLocalInfo info) {
+ if (info.hasOnlyInitMutations()) {
+ return "Mutated only in initialization";
+ }
+
+ return "Mutated in defining class";
+ }
+}
diff --git a/src/main/java/org/openrewrite/java/migrate/search/threadlocal/package-info.java b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/package-info.java
new file mode 100644
index 0000000000..34bcf91372
--- /dev/null
+++ b/src/main/java/org/openrewrite/java/migrate/search/threadlocal/package-info.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@NullMarked
+@NonNullFields
+package org.openrewrite.java.migrate.search.threadlocal;
+
+import org.jspecify.annotations.NullMarked;
+import org.openrewrite.internal.lang.NonNullFields;
diff --git a/src/main/java/org/openrewrite/java/migrate/table/ThreadLocalTable.java b/src/main/java/org/openrewrite/java/migrate/table/ThreadLocalTable.java
new file mode 100644
index 0000000000..9b69be14e4
--- /dev/null
+++ b/src/main/java/org/openrewrite/java/migrate/table/ThreadLocalTable.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.table;
+
+import lombok.Value;
+import org.openrewrite.Column;
+import org.openrewrite.DataTable;
+import org.openrewrite.Recipe;
+
+public class ThreadLocalTable extends DataTable {
+
+ public ThreadLocalTable(Recipe recipe) {
+ super(recipe,
+ "ThreadLocal usage",
+ "ThreadLocal variables and their mutation patterns.");
+ }
+
+ @Value
+ public static class Row {
+ @Column(displayName = "Source file",
+ description = "The source file containing the ThreadLocal declaration.")
+ String sourceFile;
+
+ @Column(displayName = "Class name",
+ description = "The fully qualified class name where the ThreadLocal is declared.")
+ String className;
+
+ @Column(displayName = "Field name",
+ description = "The name of the ThreadLocal field.")
+ String fieldName;
+
+ @Column(displayName = "Access modifier",
+ description = "The access modifier of the ThreadLocal field (private, protected, public, package-private).")
+ String accessModifier;
+
+ @Column(displayName = "Field modifiers",
+ description = "Additional modifiers like static, final.")
+ String modifiers;
+
+ @Column(displayName = "Mutation type",
+ description = "Type of mutation detected (Never mutated, Mutated only in initialization, Mutated in defining class, Mutated externally, Potentially mutable).")
+ String mutationType;
+
+ @Column(displayName = "Message",
+ description = "Detailed message about the ThreadLocal's usage pattern.")
+ String message;
+ }
+}
diff --git a/src/main/resources/META-INF/rewrite/recipes.csv b/src/main/resources/META-INF/rewrite/recipes.csv
index 8ec636813e..91be24dab5 100644
--- a/src/main/resources/META-INF/rewrite/recipes.csv
+++ b/src/main/resources/META-INF/rewrite/recipes.csv
@@ -397,6 +397,9 @@ maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.s
maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.search.FindInternalJavaxApis,Find uses of internal javax APIs,The libraries that define these APIs will have to be migrated before any of the repositories that use them.,1,,Search,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,"[{""name"":""methodPattern"",""type"":""String"",""displayName"":""Method pattern"",""description"":""Optionally limit the search to declarations that match the provided method pattern."",""example"":""java.util.List add(..)""}]","[{""name"":""org.openrewrite.java.table.MethodCalls"",""displayName"":""Method calls"",""description"":""The text of matching method invocations."",""columns"":[{""name"":""sourceFile"",""type"":""String"",""displayName"":""Source file"",""description"":""The source file that the method call occurred in.""},{""name"":""method"",""type"":""String"",""displayName"":""Method call"",""description"":""The text of the method call.""},{""name"":""className"",""type"":""String"",""displayName"":""Class name"",""description"":""The class name of the method call.""},{""name"":""methodName"",""type"":""String"",""displayName"":""Method name"",""description"":""The method name of the method call.""},{""name"":""argumentTypes"",""type"":""String"",""displayName"":""Argument types"",""description"":""The argument types of the method call.""}]}]"
maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.search.FindJavaVersion,Find Java versions in use,Finds Java versions in use.,1,,Search,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,,"[{""name"":""org.openrewrite.java.migrate.table.JavaVersionTable"",""displayName"":""Java version table"",""description"":""Records versions of Java in use"",""columns"":[{""name"":""sourceVersion"",""type"":""String"",""displayName"":""Source compatibility"",""description"":""The version of Java used to compile the source code""},{""name"":""targetVersion"",""type"":""String"",""displayName"":""Target compatibility"",""description"":""The version of Java the bytecode is compiled to run on""}]}]"
maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.search.PlanJavaMigration,Plan a Java version migration,Study the set of Java versions and associated tools in use across many repositories.,1,,Search,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,,"[{""name"":""org.openrewrite.java.migrate.table.JavaVersionMigrationPlan"",""displayName"":""Java version migration plan"",""description"":""A per-repository view of the current state of Java versions and associated build tools"",""columns"":[{""name"":""hasJava"",""type"":""boolean"",""displayName"":""Has Java"",""description"":""Whether this is a Java repository at all.""},{""name"":""sourceCompatibility"",""type"":""String"",""displayName"":""Source compatibility"",""description"":""The source compatibility of the source file.""},{""name"":""majorVersionSourceCompatibility"",""type"":""Integer"",""displayName"":""Major version source compatibility"",""description"":""The major version.""},{""name"":""targetCompatibility"",""type"":""String"",""displayName"":""Target compatibility"",""description"":""The target compatibility or `--release` version of the source file.""},{""name"":""gradleVersion"",""type"":""String"",""displayName"":""Gradle version"",""description"":""The version of Gradle in use, if any.""},{""name"":""hasGradleBuild"",""type"":""Boolean"",""displayName"":""Has Gradle build"",""description"":""Whether a build.gradle file exists in the repository.""},{""name"":""mavenVersion"",""type"":""String"",""displayName"":""Maven version"",""description"":""The version of Maven in use, if any.""},{""name"":""hasMavenPom"",""type"":""Boolean"",""displayName"":""Has Maven pom"",""description"":""Whether a pom.xml file exists in the repository.""}]}]"
+maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.search.threadlocal.FindNeverMutatedThreadLocals,Find ThreadLocal variables that are never mutated,Find `ThreadLocal` variables that are never mutated after initialization. These are prime candidates for migration to `ScopedValue` in Java 25+ as they are effectively immutable. The recipe identifies `ThreadLocal` variables that are only initialized but never reassigned or modified through `set()` or `remove()` methods.,1,Threadlocal,Search,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,,"[{""name"":""org.openrewrite.java.migrate.table.ThreadLocalTable"",""displayName"":""ThreadLocal usage"",""description"":""ThreadLocal variables and their mutation patterns."",""columns"":[{""name"":""sourceFile"",""type"":""String"",""displayName"":""Source file"",""description"":""The source file containing the ThreadLocal declaration.""},{""name"":""className"",""type"":""String"",""displayName"":""Class name"",""description"":""The fully qualified class name where the ThreadLocal is declared.""},{""name"":""fieldName"",""type"":""String"",""displayName"":""Field name"",""description"":""The name of the ThreadLocal field.""},{""name"":""accessModifier"",""type"":""String"",""displayName"":""Access modifier"",""description"":""The access modifier of the ThreadLocal field (private, protected, public, package-private).""},{""name"":""modifiers"",""type"":""String"",""displayName"":""Field modifiers"",""description"":""Additional modifiers like static, final.""},{""name"":""mutationType"",""type"":""String"",""displayName"":""Mutation type"",""description"":""Type of mutation detected (Never mutated, Mutated only in initialization, Mutated in defining class, Mutated externally, Potentially mutable).""},{""name"":""message"",""type"":""String"",""displayName"":""Message"",""description"":""Detailed message about the ThreadLocal's usage pattern.""}]}]"
+maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.search.threadlocal.FindThreadLocalsMutableFromOutside,Find ThreadLocal variables mutable from outside their defining class,Find `ThreadLocal` variables that can be mutated from outside their defining class. These ThreadLocals have the highest risk as they can be modified by any code with access to them. This includes non-private ThreadLocals or those mutated from other classes in the codebase.,1,Threadlocal,Search,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,,"[{""name"":""org.openrewrite.java.migrate.table.ThreadLocalTable"",""displayName"":""ThreadLocal usage"",""description"":""ThreadLocal variables and their mutation patterns."",""columns"":[{""name"":""sourceFile"",""type"":""String"",""displayName"":""Source file"",""description"":""The source file containing the ThreadLocal declaration.""},{""name"":""className"",""type"":""String"",""displayName"":""Class name"",""description"":""The fully qualified class name where the ThreadLocal is declared.""},{""name"":""fieldName"",""type"":""String"",""displayName"":""Field name"",""description"":""The name of the ThreadLocal field.""},{""name"":""accessModifier"",""type"":""String"",""displayName"":""Access modifier"",""description"":""The access modifier of the ThreadLocal field (private, protected, public, package-private).""},{""name"":""modifiers"",""type"":""String"",""displayName"":""Field modifiers"",""description"":""Additional modifiers like static, final.""},{""name"":""mutationType"",""type"":""String"",""displayName"":""Mutation type"",""description"":""Type of mutation detected (Never mutated, Mutated only in initialization, Mutated in defining class, Mutated externally, Potentially mutable).""},{""name"":""message"",""type"":""String"",""displayName"":""Message"",""description"":""Detailed message about the ThreadLocal's usage pattern.""}]}]"
+maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.search.threadlocal.FindThreadLocalsMutatedOnlyInDefiningScope,Find ThreadLocal variables mutated only in their defining scope,Find `ThreadLocal` variables that are only mutated within their defining class or initialization context (constructor/static initializer). These may be candidates for refactoring as they have limited mutation scope. The recipe identifies `ThreadLocal` variables that are only modified during initialization or within their declaring class.,1,Threadlocal,Search,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,,"[{""name"":""org.openrewrite.java.migrate.table.ThreadLocalTable"",""displayName"":""ThreadLocal usage"",""description"":""ThreadLocal variables and their mutation patterns."",""columns"":[{""name"":""sourceFile"",""type"":""String"",""displayName"":""Source file"",""description"":""The source file containing the ThreadLocal declaration.""},{""name"":""className"",""type"":""String"",""displayName"":""Class name"",""description"":""The fully qualified class name where the ThreadLocal is declared.""},{""name"":""fieldName"",""type"":""String"",""displayName"":""Field name"",""description"":""The name of the ThreadLocal field.""},{""name"":""accessModifier"",""type"":""String"",""displayName"":""Access modifier"",""description"":""The access modifier of the ThreadLocal field (private, protected, public, package-private).""},{""name"":""modifiers"",""type"":""String"",""displayName"":""Field modifiers"",""description"":""Additional modifiers like static, final.""},{""name"":""mutationType"",""type"":""String"",""displayName"":""Mutation type"",""description"":""Type of mutation detected (Never mutated, Mutated only in initialization, Mutated in defining class, Mutated externally, Potentially mutable).""},{""name"":""message"",""type"":""String"",""displayName"":""Message"",""description"":""Detailed message about the ThreadLocal's usage pattern.""}]}]"
maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.sql.MigrateDriverManagerSetLogStream,Use `DriverManager#setLogWriter(java.io.PrintWriter)`,Use `DriverManager#setLogWriter(java.io.PrintWriter)` instead of the deprecated `DriverManager#setLogStream(java.io.PrintStream)` in Java 1.2 or higher.,1,,`java.sql` APIs,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,,
maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.sql.JavaSqlAPIs,Use modernized `java.sql` APIs,"Certain Java sql APIs have become deprecated and their usages changed, necessitating usage changes.",3,,`java.sql` APIs,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,,
maven,org.openrewrite.recipe:rewrite-migrate-java,org.openrewrite.java.migrate.util.IteratorNext,Replace `iterator().next()` with `getFirst()`,Replace `SequencedCollection.iterator().next()` with `getFirst()`.,1,,`java.util` APIs,Modernize,Java,,,Modernize your code to best use the project's current JDK version. Take advantage of newly available APIs and reduce the dependency of your code on third party dependencies where there is equivalent functionality in the Java standard library.,Basic building blocks for transforming Java code.,,
diff --git a/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocalsCrossFileTest.java b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocalsCrossFileTest.java
new file mode 100644
index 0000000000..1ab12ea666
--- /dev/null
+++ b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocalsCrossFileTest.java
@@ -0,0 +1,458 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.search.threadlocal;
+
+import org.junit.jupiter.api.Test;
+import org.openrewrite.DocumentExample;
+import org.openrewrite.java.migrate.table.ThreadLocalTable;
+import org.openrewrite.test.RecipeSpec;
+import org.openrewrite.test.RewriteTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.openrewrite.java.Assertions.java;
+
+class FindNeverMutatedThreadLocalsCrossFileTest implements RewriteTest {
+
+ @Override
+ public void defaults(RecipeSpec spec) {
+ spec.recipe(new FindNeverMutatedThreadLocals());
+ }
+
+ @DocumentExample
+ @Test
+ void detectMutationFromAnotherClassInSamePackage() {
+ rewriteRun(
+ // First class with package-private ThreadLocal
+ java(
+ """
+ package com.example;
+
+ class ThreadLocalHolder {
+ static final ThreadLocal SHARED_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return SHARED_TL.get();
+ }
+ }
+ """
+ ),
+ // Second class that mutates the ThreadLocal
+ java(
+ """
+ package com.example;
+
+ class ThreadLocalMutator {
+ public void mutate() {
+ ThreadLocalHolder.SHARED_TL.set("mutated");
+ }
+
+ public void cleanup() {
+ ThreadLocalHolder.SHARED_TL.remove();
+ }
+ }
+ """
+ )
+ );
+ // The ThreadLocal should NOT be marked as immutable because it's mutated in ThreadLocalMutator
+ }
+
+ @Test
+ void detectNoMutationAcrossMultipleClasses() {
+ rewriteRun(
+ java(
+ """
+ package com.example;
+
+ public class ReadOnlyHolder {
+ public static final ThreadLocal COUNTER = ThreadLocal.withInitial(() -> 0);
+
+ public int getCount() {
+ return COUNTER.get();
+ }
+ }
+ """,
+ """
+ package com.example;
+
+ public class ReadOnlyHolder {
+ /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/public static final ThreadLocal COUNTER = ThreadLocal.withInitial(() -> 0);
+
+ public int getCount() {
+ return COUNTER.get();
+ }
+ }
+ """
+ ),
+ java(
+ """
+ package com.example;
+
+ class Reader1 {
+ public void readValue() {
+ Integer value = ReadOnlyHolder.COUNTER.get();
+ System.out.println(value);
+ }
+ }
+ """
+ ),
+ java(
+ """
+ package com.example;
+
+ class Reader2 {
+ public int calculate() {
+ return ReadOnlyHolder.COUNTER.get() + 10;
+ }
+ }
+ """
+ )
+ );
+ // The ThreadLocal should be marked with a warning since it's public but never mutated
+ }
+
+ @Test
+ void detectMutationThroughInheritance() {
+ rewriteRun(
+ java(
+ """
+ package com.example;
+
+ public class BaseClass {
+ protected static final ThreadLocal PROTECTED_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return PROTECTED_TL.get();
+ }
+ }
+ """
+ ),
+ java(
+ """
+ package com.example.sub;
+
+ import com.example.BaseClass;
+
+ public class SubClass extends BaseClass {
+ public void modifyThreadLocal() {
+ PROTECTED_TL.set("modified by subclass");
+ }
+ }
+ """
+ )
+ );
+ // The ThreadLocal should NOT be marked as immutable because it's mutated in SubClass
+ }
+
+ @Test
+ void privateThreadLocalNotAccessibleFromOtherClass() {
+ rewriteRun(
+ java(
+ """
+ package com.example;
+
+ public class PrivateHolder {
+ private static final ThreadLocal PRIVATE_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return PRIVATE_TL.get();
+ }
+
+ public static class NestedClass {
+ public void tryToAccess() {
+ // Can access private field from nested class
+ String value = PRIVATE_TL.get();
+ }
+ }
+ }
+ """,
+ """
+ package com.example;
+
+ public class PrivateHolder {
+ /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final ThreadLocal PRIVATE_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return PRIVATE_TL.get();
+ }
+
+ public static class NestedClass {
+ public void tryToAccess() {
+ // Can access private field from nested class
+ String value = PRIVATE_TL.get();
+ }
+ }
+ }
+ """
+ ),
+ java(
+ """
+ package com.example;
+
+ class ExternalClass {
+ public void cannotAccess() {
+ // Cannot access private ThreadLocal from PrivateHolder
+ // This class cannot mutate PRIVATE_TL
+ }
+ }
+ """
+ )
+ );
+ // Private ThreadLocal should be marked as immutable since it can't be mutated externally
+ }
+
+ @Test
+ void detectMutationInNestedClass() {
+ rewriteRun(
+ java(
+ """
+ package com.example;
+
+ public class OuterClass {
+ private static final ThreadLocal TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return TL.get();
+ }
+
+ public static class InnerClass {
+ public void mutate() {
+ TL.set("mutated by inner class");
+ }
+ }
+ }
+ """
+ )
+ );
+ // ThreadLocal should NOT be marked as immutable because it's mutated in InnerClass
+ }
+
+ @Test
+ void detectMutationThroughStaticImport() {
+ rewriteRun(
+ java(
+ """
+ package com.example;
+
+ public class ThreadLocalProvider {
+ public static final ThreadLocal STATIC_TL = new ThreadLocal<>();
+ }
+ """
+ ),
+ java(
+ """
+ package com.example.user;
+
+ import static com.example.ThreadLocalProvider.STATIC_TL;
+
+ public class StaticImportUser {
+ public void mutate() {
+ STATIC_TL.set("mutated through static import");
+ }
+ }
+ """
+ )
+ );
+ // ThreadLocal should NOT be marked as immutable due to mutation through static import
+ }
+
+ @Test
+ void multipleThreadLocalsWithMixedAccessPatterns() {
+ rewriteRun(
+ java(
+ """
+ package com.example;
+
+ public class MultipleThreadLocals {
+ private static final ThreadLocal PRIVATE_IMMUTABLE = new ThreadLocal<>();
+ static final ThreadLocal PACKAGE_MUTATED = new ThreadLocal<>();
+ public static final ThreadLocal PUBLIC_READ_ONLY = new ThreadLocal<>();
+ protected static final ThreadLocal PROTECTED_MUTATED = new ThreadLocal<>();
+
+ public void readAll() {
+ String p1 = PRIVATE_IMMUTABLE.get();
+ String p2 = PACKAGE_MUTATED.get();
+ String p3 = PUBLIC_READ_ONLY.get();
+ String p4 = PROTECTED_MUTATED.get();
+ }
+ }
+ """,
+ """
+ package com.example;
+
+ public class MultipleThreadLocals {
+ /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final ThreadLocal PRIVATE_IMMUTABLE = new ThreadLocal<>();
+ static final ThreadLocal PACKAGE_MUTATED = new ThreadLocal<>();
+ /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/public static final ThreadLocal PUBLIC_READ_ONLY = new ThreadLocal<>();
+ protected static final ThreadLocal PROTECTED_MUTATED = new ThreadLocal<>();
+
+ public void readAll() {
+ String p1 = PRIVATE_IMMUTABLE.get();
+ String p2 = PACKAGE_MUTATED.get();
+ String p3 = PUBLIC_READ_ONLY.get();
+ String p4 = PROTECTED_MUTATED.get();
+ }
+ }
+ """
+ ),
+ java(
+ """
+ package com.example;
+
+ class Mutator {
+ public void mutate() {
+ MultipleThreadLocals.PACKAGE_MUTATED.set("mutated");
+ MultipleThreadLocals.PROTECTED_MUTATED.remove();
+ }
+
+ public void readOnly() {
+ String value = MultipleThreadLocals.PUBLIC_READ_ONLY.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void detectMutationThroughMethodReference() {
+ rewriteRun(
+ java(
+ """
+ package com.example;
+
+ import java.util.function.Consumer;
+
+ public class MethodReferenceExample {
+ public static final ThreadLocal TL = new ThreadLocal<>();
+
+ public static void setValue(String value) {
+ TL.set(value);
+ }
+
+ public void useMethodReference() {
+ Consumer setter = MethodReferenceExample::setValue;
+ setter.accept("value");
+ }
+ }
+ """
+ )
+ );
+ // ThreadLocal is mutated through setValue method, should NOT be marked as immutable
+ }
+
+ @Test
+ void detectIndirectMutationThroughPublicSetter() {
+ rewriteRun(
+ java(
+ """
+ package com.example;
+
+ public class IndirectMutation {
+ private static final ThreadLocal PRIVATE_TL = new ThreadLocal<>();
+
+ public static void setThreadLocalValue(String value) {
+ PRIVATE_TL.set(value);
+ }
+
+ public static String getThreadLocalValue() {
+ return PRIVATE_TL.get();
+ }
+ }
+ """
+ ),
+ java(
+ """
+ package com.example;
+
+ class ExternalSetter {
+ public void mutateIndirectly() {
+ IndirectMutation.setThreadLocalValue("mutated");
+ }
+ }
+ """
+ )
+ );
+ // ThreadLocal should NOT be marked as immutable because setThreadLocalValue mutates it
+ }
+
+ @Test
+ void instanceThreadLocalWithCrossFileAccess() {
+ rewriteRun(
+ java(
+ """
+ package com.example;
+
+ public class InstanceThreadLocalHolder {
+ public final ThreadLocal instanceTL = new ThreadLocal<>();
+
+ public String getValue() {
+ return instanceTL.get();
+ }
+ }
+ """
+ ),
+ java(
+ """
+ package com.example;
+
+ class InstanceMutator {
+ public void mutate(InstanceThreadLocalHolder holder) {
+ holder.instanceTL.set("mutated");
+ }
+ }
+ """
+ )
+ );
+ // Instance ThreadLocal should NOT be marked as immutable due to external mutation
+ }
+
+ @Test
+ void verifyDataTableOutputCrossFile() {
+ rewriteRun(
+ spec -> spec.dataTable(ThreadLocalTable.Row.class, rows -> {
+ assertThat(rows).hasSize(1);
+ assertThat(rows.get(0).getClassName()).isEqualTo("com.example.Holder");
+ assertThat(rows.get(0).getFieldName()).isEqualTo("TL");
+ assertThat(rows.get(0).getMutationType()).isEqualTo("Never mutated");
+ }),
+ java(
+ """
+ package com.example;
+
+ class Holder {
+ private static final ThreadLocal TL = new ThreadLocal<>();
+ }
+ """,
+ """
+ package com.example;
+
+ class Holder {
+ /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final ThreadLocal TL = new ThreadLocal<>();
+ }
+ """
+ ),
+ java(
+ """
+ package com.example;
+
+ class Reader {
+ // Only reads, no mutation
+ }
+ """
+ )
+ );
+ }
+}
diff --git a/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocalsTest.java b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocalsTest.java
new file mode 100644
index 0000000000..4c8fa83527
--- /dev/null
+++ b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocalsTest.java
@@ -0,0 +1,378 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.search.threadlocal;
+
+import org.junit.jupiter.api.Test;
+import org.openrewrite.DocumentExample;
+import org.openrewrite.java.migrate.table.ThreadLocalTable;
+import org.openrewrite.test.RecipeSpec;
+import org.openrewrite.test.RewriteTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.openrewrite.java.Assertions.java;
+
+class FindNeverMutatedThreadLocalsTest implements RewriteTest {
+
+ @Override
+ public void defaults(RecipeSpec spec) {
+ spec.recipe(new FindNeverMutatedThreadLocals());
+ }
+
+ @DocumentExample
+ @Test
+ void identifySimpleImmutableThreadLocal() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ private static final ThreadLocal TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return TL.get();
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final ThreadLocal TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return TL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void identifyThreadLocalWithInitialValue() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ private static final ThreadLocal COUNTER = ThreadLocal.withInitial(() -> 0);
+
+ public int getCount() {
+ return COUNTER.get();
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final ThreadLocal COUNTER = ThreadLocal.withInitial(() -> 0);
+
+ public int getCount() {
+ return COUNTER.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void doNotMarkThreadLocalWithSetCall() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ private static final ThreadLocal TL = new ThreadLocal<>();
+
+ public void setValue(String value) {
+ TL.set(value);
+ }
+
+ public String getValue() {
+ return TL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void doNotMarkThreadLocalWithRemoveCall() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ private static final ThreadLocal TL = new ThreadLocal<>();
+
+ public void cleanup() {
+ TL.remove();
+ }
+
+ public String getValue() {
+ return TL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void doNotMarkReassignedThreadLocal() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ private static ThreadLocal tl = new ThreadLocal<>();
+
+ public void reset() {
+ tl = new ThreadLocal<>();
+ }
+
+ public String getValue() {
+ return tl.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void handleMultipleThreadLocalsWithMixedPatterns() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ private static final ThreadLocal IMMUTABLE_TL = new ThreadLocal<>();
+ private static final ThreadLocal MUTABLE_TL = new ThreadLocal<>();
+ private static final ThreadLocal ANOTHER_IMMUTABLE = ThreadLocal.withInitial(() -> false);
+
+ public void updateMutable(int value) {
+ MUTABLE_TL.set(value);
+ }
+
+ public String getImmutable() {
+ return IMMUTABLE_TL.get();
+ }
+
+ public Boolean getAnother() {
+ return ANOTHER_IMMUTABLE.get();
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final ThreadLocal IMMUTABLE_TL = new ThreadLocal<>();
+ private static final ThreadLocal MUTABLE_TL = new ThreadLocal<>();
+ /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final ThreadLocal ANOTHER_IMMUTABLE = ThreadLocal.withInitial(() -> false);
+
+ public void updateMutable(int value) {
+ MUTABLE_TL.set(value);
+ }
+
+ public String getImmutable() {
+ return IMMUTABLE_TL.get();
+ }
+
+ public Boolean getAnother() {
+ return ANOTHER_IMMUTABLE.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void identifyInstanceThreadLocal() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ private final ThreadLocal instanceTL = new ThreadLocal<>();
+
+ public String getValue() {
+ return instanceTL.get();
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private final ThreadLocal instanceTL = new ThreadLocal<>();
+
+ public String getValue() {
+ return instanceTL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void handleThreadLocalWithComplexInitialization() {
+ rewriteRun(
+ java(
+ """
+ import java.text.SimpleDateFormat;
+
+ class Example {
+ private static final ThreadLocal DATE_FORMAT =
+ ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
+
+ public String formatDate(java.util.Date date) {
+ return DATE_FORMAT.get().format(date);
+ }
+ }
+ """,
+ """
+ import java.text.SimpleDateFormat;
+
+ class Example {
+ /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final ThreadLocal DATE_FORMAT =
+ ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
+
+ public String formatDate(java.util.Date date) {
+ return DATE_FORMAT.get().format(date);
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void doNotMarkLocalVariableThreadLocal() {
+ // Local ThreadLocals are unusual but should not be marked as they have different lifecycle
+ rewriteRun(
+ java(
+ """
+ class Example {
+ public void method() {
+ ThreadLocal localTL = new ThreadLocal<>();
+ localTL.set("value");
+ System.out.println(localTL.get());
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void warnAboutPackagePrivateThreadLocal() {
+ // Package-private ThreadLocals without mutations are still flagged by FindNeverMutatedThreadLocals
+ // but should NOT be flagged by FindThreadLocalsMutatableFromOutside if they have no mutations
+ // For this test, we'll test that it's flagged as never mutated
+ rewriteRun(
+ java(
+ """
+ class Example {
+ static final ThreadLocal PACKAGE_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return PACKAGE_TL.get();
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/static final ThreadLocal PACKAGE_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return PACKAGE_TL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void warnAboutProtectedThreadLocal() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ protected static final ThreadLocal PROTECTED_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return PROTECTED_TL.get();
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/protected static final ThreadLocal PROTECTED_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return PROTECTED_TL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void handleInheritableThreadLocal() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ private static final InheritableThreadLocal ITL = new InheritableThreadLocal<>();
+
+ public String getValue() {
+ return ITL.get();
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final InheritableThreadLocal ITL = new InheritableThreadLocal<>();
+
+ public String getValue() {
+ return ITL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void verifyDataTableOutput() {
+ rewriteRun(
+ spec -> spec.dataTable(ThreadLocalTable.Row.class, rows -> {
+ assertThat(rows).hasSize(1);
+ assertThat(rows.get(0).getClassName()).isEqualTo("Example");
+ assertThat(rows.get(0).getFieldName()).isEqualTo("TL");
+ assertThat(rows.get(0).getAccessModifier()).isEqualTo("private");
+ assertThat(rows.get(0).getModifiers()).isEqualTo("static final");
+ assertThat(rows.get(0).getMutationType()).isEqualTo("Never mutated");
+ }),
+ java(
+ """
+ class Example {
+ private static final ThreadLocal TL = new ThreadLocal<>();
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is never mutated and could be replaced with ScopedValue)~~>*/private static final ThreadLocal TL = new ThreadLocal<>();
+ }
+ """
+ )
+ );
+ }
+}
diff --git a/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutableFromOutsideTest.java b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutableFromOutsideTest.java
new file mode 100644
index 0000000000..cc6feb8140
--- /dev/null
+++ b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutableFromOutsideTest.java
@@ -0,0 +1,281 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.search.threadlocal;
+
+import org.junit.jupiter.api.Test;
+import org.openrewrite.DocumentExample;
+import org.openrewrite.java.migrate.table.ThreadLocalTable;
+import org.openrewrite.test.RecipeSpec;
+import org.openrewrite.test.RewriteTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.openrewrite.java.Assertions.java;
+
+class FindThreadLocalsMutableFromOutsideTest implements RewriteTest {
+
+ @Override
+ public void defaults(RecipeSpec spec) {
+ spec.recipe(new FindThreadLocalsMutableFromOutside());
+ }
+
+ @DocumentExample
+ @Test
+ void identifyNonPrivateThreadLocal() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ public static final ThreadLocal PUBLIC_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return PUBLIC_TL.get();
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is static non-private and can potentially be mutated from outside)~~>*/public static final ThreadLocal PUBLIC_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return PUBLIC_TL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void identifyPackagePrivateThreadLocal() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ static final ThreadLocal PACKAGE_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return PACKAGE_TL.get();
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is static non-private and can potentially be mutated from outside)~~>*/static final ThreadLocal PACKAGE_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return PACKAGE_TL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void identifyProtectedThreadLocal() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ protected static final ThreadLocal PROTECTED_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return PROTECTED_TL.get();
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is static non-private and can potentially be mutated from outside)~~>*/protected static final ThreadLocal PROTECTED_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return PROTECTED_TL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void doNotMarkPrivateThreadLocal() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ private static final ThreadLocal PRIVATE_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return PRIVATE_TL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void identifyThreadLocalMutatedFromOutside() {
+ rewriteRun(
+ java(
+ """
+ package com.example;
+
+ public class ThreadLocalHolder {
+ public static final ThreadLocal SHARED_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return SHARED_TL.get();
+ }
+ }
+ """,
+ """
+ package com.example;
+
+ public class ThreadLocalHolder {
+ /*~~(ThreadLocal is mutated from outside its defining class)~~>*/public static final ThreadLocal SHARED_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return SHARED_TL.get();
+ }
+ }
+ """
+ ),
+ java(
+ """
+ package com.example;
+
+ class ThreadLocalMutator {
+ public void mutate() {
+ ThreadLocalHolder.SHARED_TL.set("mutated");
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void identifyInstanceThreadLocalNonPrivate() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ public final ThreadLocal instanceTL = new ThreadLocal<>();
+
+ public String getValue() {
+ return instanceTL.get();
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is non-private and can potentially be mutated from outside)~~>*/public final ThreadLocal instanceTL = new ThreadLocal<>();
+
+ public String getValue() {
+ return instanceTL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void doNotMarkPrivateInstanceThreadLocal() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ private final ThreadLocal instanceTL = new ThreadLocal<>();
+
+ public String getValue() {
+ return instanceTL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void identifyProtectedMutatedFromSubclass() {
+ rewriteRun(
+ java(
+ """
+ package com.example;
+
+ public class BaseClass {
+ protected static final ThreadLocal PROTECTED_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return PROTECTED_TL.get();
+ }
+ }
+ """,
+ """
+ package com.example;
+
+ public class BaseClass {
+ /*~~(ThreadLocal is mutated from outside its defining class)~~>*/protected static final ThreadLocal PROTECTED_TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return PROTECTED_TL.get();
+ }
+ }
+ """
+ ),
+ java(
+ """
+ package com.example.sub;
+
+ import com.example.BaseClass;
+
+ public class SubClass extends BaseClass {
+ public void modifyThreadLocal() {
+ PROTECTED_TL.set("modified by subclass");
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void verifyDataTableOutput() {
+ rewriteRun(
+ spec -> spec.dataTable(ThreadLocalTable.Row.class, rows -> {
+ assertThat(rows).hasSize(1);
+ assertThat(rows.get(0).getClassName()).isEqualTo("Example");
+ assertThat(rows.get(0).getFieldName()).isEqualTo("PUBLIC_TL");
+ assertThat(rows.get(0).getAccessModifier()).isEqualTo("public");
+ assertThat(rows.get(0).getMutationType()).isEqualTo("Potentially mutable");
+ }),
+ java(
+ """
+ class Example {
+ public static final ThreadLocal PUBLIC_TL = new ThreadLocal<>();
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is static non-private and can potentially be mutated from outside)~~>*/public static final ThreadLocal PUBLIC_TL = new ThreadLocal<>();
+ }
+ """
+ )
+ );
+ }
+}
diff --git a/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutatedOnlyInDefiningScopeTest.java b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutatedOnlyInDefiningScopeTest.java
new file mode 100644
index 0000000000..9eb36aee0e
--- /dev/null
+++ b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutatedOnlyInDefiningScopeTest.java
@@ -0,0 +1,289 @@
+/*
+ * Copyright 2024 the original author or authors.
+ *
+ * Licensed under the Moderne Source Available License (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://docs.moderne.io/licensing/moderne-source-available-license
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.openrewrite.java.migrate.search.threadlocal;
+
+import org.junit.jupiter.api.Test;
+import org.openrewrite.DocumentExample;
+import org.openrewrite.java.migrate.table.ThreadLocalTable;
+import org.openrewrite.test.RecipeSpec;
+import org.openrewrite.test.RewriteTest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.openrewrite.java.Assertions.java;
+
+class FindThreadLocalsMutatedOnlyInDefiningScopeTest implements RewriteTest {
+
+ @Override
+ public void defaults(RecipeSpec spec) {
+ spec.recipe(new FindThreadLocalsMutatedOnlyInDefiningScope());
+ }
+
+ @DocumentExample
+ @Test
+ void identifyThreadLocalMutatedOnlyInConstructor() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ private final ThreadLocal TL = new ThreadLocal<>();
+
+ public Example() {
+ TL.set("initial value");
+ }
+
+ public String getValue() {
+ return TL.get();
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is only mutated during initialization (constructor/static initializer))~~>*/private final ThreadLocal TL = new ThreadLocal<>();
+
+ public Example() {
+ TL.set("initial value");
+ }
+
+ public String getValue() {
+ return TL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void identifyThreadLocalMutatedOnlyInStaticInitializer() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ private static final ThreadLocal TL = new ThreadLocal<>();
+
+ static {
+ TL.set("static initial value");
+ }
+
+ public String getValue() {
+ return TL.get();
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is only mutated during initialization (constructor/static initializer))~~>*/private static final ThreadLocal TL = new ThreadLocal<>();
+
+ static {
+ TL.set("static initial value");
+ }
+
+ public String getValue() {
+ return TL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void identifyThreadLocalMutatedInDefiningClass() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ private static final ThreadLocal TL = new ThreadLocal<>();
+
+ public void setValue(String value) {
+ TL.set(value);
+ }
+
+ public String getValue() {
+ return TL.get();
+ }
+
+ public void cleanup() {
+ TL.remove();
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is only mutated within its defining class)~~>*/private static final ThreadLocal TL = new ThreadLocal<>();
+
+ public void setValue(String value) {
+ TL.set(value);
+ }
+
+ public String getValue() {
+ return TL.get();
+ }
+
+ public void cleanup() {
+ TL.remove();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void doNotMarkThreadLocalNeverMutated() {
+ // This should not be marked by this recipe - it's for FindNeverMutatedThreadLocals
+ rewriteRun(
+ java(
+ """
+ class Example {
+ private static final ThreadLocal TL = new ThreadLocal<>();
+
+ public String getValue() {
+ return TL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void doNotMarkNonPrivateThreadLocal() {
+ // Non-private ThreadLocals shouldn't be marked by this recipe
+ rewriteRun(
+ java(
+ """
+ class Example {
+ static final ThreadLocal TL = new ThreadLocal<>();
+
+ public void setValue(String value) {
+ TL.set(value);
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void identifyInstanceThreadLocalMutatedInConstructor() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ private final ThreadLocal counter = new ThreadLocal<>();
+
+ public Example(int initial) {
+ counter.set(initial);
+ }
+
+ public Integer getCount() {
+ return counter.get();
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is only mutated during initialization (constructor/static initializer))~~>*/private final ThreadLocal counter = new ThreadLocal<>();
+
+ public Example(int initial) {
+ counter.set(initial);
+ }
+
+ public Integer getCount() {
+ return counter.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void identifyThreadLocalWithMixedInitAndClassMutations() {
+ rewriteRun(
+ java(
+ """
+ class Example {
+ private static final ThreadLocal TL = new ThreadLocal<>();
+
+ static {
+ TL.set("initial");
+ }
+
+ public void update(String value) {
+ TL.set(value);
+ }
+
+ public String getValue() {
+ return TL.get();
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is only mutated within its defining class)~~>*/private static final ThreadLocal TL = new ThreadLocal<>();
+
+ static {
+ TL.set("initial");
+ }
+
+ public void update(String value) {
+ TL.set(value);
+ }
+
+ public String getValue() {
+ return TL.get();
+ }
+ }
+ """
+ )
+ );
+ }
+
+ @Test
+ void verifyDataTableOutput() {
+ rewriteRun(
+ spec -> spec.dataTable(ThreadLocalTable.Row.class, rows -> {
+ assertThat(rows).hasSize(1);
+ assertThat(rows.get(0).getClassName()).isEqualTo("Example");
+ assertThat(rows.get(0).getFieldName()).isEqualTo("TL");
+ assertThat(rows.get(0).getAccessModifier()).isEqualTo("private");
+ assertThat(rows.get(0).getMutationType()).isEqualTo("Mutated only in initialization");
+ }),
+ java(
+ """
+ class Example {
+ private static final ThreadLocal TL = new ThreadLocal<>();
+
+ static {
+ TL.set("initial");
+ }
+ }
+ """,
+ """
+ class Example {
+ /*~~(ThreadLocal is only mutated during initialization (constructor/static initializer))~~>*/private static final ThreadLocal TL = new ThreadLocal<>();
+
+ static {
+ TL.set("initial");
+ }
+ }
+ """
+ )
+ );
+ }
+}