diff --git a/src/main/java/org/openrewrite/java/migrate/search/ThreadLocalTable.java b/src/main/java/org/openrewrite/java/migrate/search/ThreadLocalTable.java
new file mode 100644
index 0000000000..13051ccaf5
--- /dev/null
+++ b/src/main/java/org/openrewrite/java/migrate/search/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.search;
+
+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/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..1b32173630
--- /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.search.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/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..d19906a99d
--- /dev/null
+++ b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocalsCrossFileTest.java
@@ -0,0 +1,419 @@
+/*
+ * 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.test.RecipeSpec;
+import org.openrewrite.test.RewriteTest;
+
+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
+ }
+}
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..03f2add0a1
--- /dev/null
+++ b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindNeverMutatedThreadLocalsTest.java
@@ -0,0 +1,350 @@
+/*
+ * 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.test.RecipeSpec;
+import org.openrewrite.test.RewriteTest;
+
+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();
+ }
+ }
+ """
+ )
+ );
+ }
+}
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..56308de926
--- /dev/null
+++ b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutableFromOutsideTest.java
@@ -0,0 +1,254 @@
+/*
+ * 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.test.RecipeSpec;
+import org.openrewrite.test.RewriteTest;
+
+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");
+ }
+ }
+ """
+ )
+ );
+ }
+}
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..d496183db8
--- /dev/null
+++ b/src/test/java/org/openrewrite/java/migrate/search/threadlocal/FindThreadLocalsMutatedOnlyInDefiningScopeTest.java
@@ -0,0 +1,254 @@
+/*
+ * 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.test.RecipeSpec;
+import org.openrewrite.test.RewriteTest;
+
+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();
+ }
+ }
+ """
+ )
+ );
+ }
+}