Repository: openrewrite/rewrite (Java)

Issue:
Yaml recipes in the list should not be run if single source applicability tests failed
Thanks @shanman190 for discovering and reporting this as part of https://github.com/openrewrite/rewrite/issues/2746

this issue only happened with recipes that have the `visit(List<SourceFile> before, ExecutionContext ctx)` method overridden.

A recipe has two different ways to run.
- 1. Override `getVisitor()` method, and invoke `getVisitor().visit(@Nullable Tree tree, P p)` to transform a single file.
- 2. Override `visit(List<SourceFile> before, ExecutionContext ctx)` method, and invoke it to transform multiple files.
Typically, for a recipe, only one of the two methods mentioned above is required to be overridden.
The recipe scheduler invokes both methods in different places in the flow, all recipes in the list should not be run only when the applicability test fail.

This yaml recipe can reproduce the problem when applied to a plain text file containing the text "2", expected to have no change.

```yaml
          ---
          type: specs.openrewrite.org/v1beta/recipe
          name: org.openrewrite.RecipesToTransformMultiFiles
          displayName: Recipes in the list which override 'visit()' method should not be run if applicability test fail
          description: >
            A recipe has two different ways to run.
            1. Override 'getVisitor()' method, and invoke `getVisitor().visit(@Nullable Tree tree, P p)` to transform a single file.
            2. Override `visit(List<SourceFile> before, ExecutionContext ctx)` method, and invoke it to transform multiple files.
            Typically, for a recipe, only one of the two methods mentioned above is required to be overridden.
            The recipe scheduler invokes both methods in different places in the flow, this test is intended to make sure 
            those recipes that overrides 'visit()' method can be run correctly with the applicability test.
            
            All recipes in the list should not be run only when the applicability test fail.
            
            Given a text file containing the number "2", running this recipe should result with no change.
          applicability:
            singleSource:
              - org.openrewrite.text.FindAndReplace:
                  find: "1"
                  replace: "A"
          recipeList:
            - org.openrewrite.text.AppendToTextFile:
                  relativeFileName: "file.txt"
                  content: "->2"
                  preamble: "preamble"
                  appendNewline : false
                  existingFileStrategy: "continue"
            - org.openrewrite.text.AppendToTextFile:
                  relativeFileName: "file.txt"
                  content: "->3"
                  preamble: "preamble"
                  appendNewline : false
                  existingFileStrategy: "continue"
```



Fix (patch):
diff --git a/rewrite-core/src/main/java/org/openrewrite/RecipeScheduler.java b/rewrite-core/src/main/java/org/openrewrite/RecipeScheduler.java
index e7d60889e2..6edc38cc77 100644
--- a/rewrite-core/src/main/java/org/openrewrite/RecipeScheduler.java
+++ b/rewrite-core/src/main/java/org/openrewrite/RecipeScheduler.java
@@ -179,7 +179,7 @@ default RecipeRun scheduleRun(Recipe recipe,
     default <S extends SourceFile> List<S> scheduleVisit(RecipeRunStats runStats,
                                                          Stack<Recipe> recipeStack,
                                                          List<S> before,
-                                                         @Nullable List<Boolean> singleSourceApplicableTestResult,
+                                                         @Nullable Map<UUID, Boolean> singleSourceApplicableTestResult,
                                                          ExecutionContext ctx,
                                                          Map<UUID, Stack<Recipe>> recipeThatAddedOrDeletedSourceFile) {
         runStats.calls.incrementAndGet();
@@ -216,7 +216,7 @@ default <S extends SourceFile> List<S> scheduleVisit(RecipeRunStats runStats,
             if (!recipe.getSingleSourceApplicableTests().isEmpty()) {
                 if (singleSourceApplicableTestResult == null || singleSourceApplicableTestResult.isEmpty()) {
                     if (singleSourceApplicableTestResult == null) {
-                        singleSourceApplicableTestResult = new ArrayList<>(before.size());
+                        singleSourceApplicableTestResult = new HashMap<>(before.size());
                     }
 
                     for (S s : before) {
@@ -227,7 +227,7 @@ default <S extends SourceFile> List<S> scheduleVisit(RecipeRunStats runStats,
                                 break;
                             }
                         }
-                        singleSourceApplicableTestResult.add(allMatch);
+                        singleSourceApplicableTestResult.put(s.getId(), allMatch);
                     }
                 }
             }
@@ -238,7 +238,9 @@ default <S extends SourceFile> List<S> scheduleVisit(RecipeRunStats runStats,
         SourcesFileErrors errorsTable = new SourcesFileErrors(Recipe.noop());
         AtomicBoolean thrownErrorOnTimeout = new AtomicBoolean(false);
         List<S> after;
-        final List<Boolean> newSingleSourceApplicableTestResult = singleSourceApplicableTestResult;
+        final Map<UUID, Boolean> singleSourceApplicableTestResultRef = singleSourceApplicableTestResult;
+        boolean hasSingleSourceApplicableTest = singleSourceApplicableTestResult != null
+                && !singleSourceApplicableTestResult.isEmpty();
 
         if (!recipe.validate(ctx).isValid()) {
             after = before;
@@ -250,10 +252,9 @@ default <S extends SourceFile> List<S> scheduleVisit(RecipeRunStats runStats,
 
                 S afterFile = s;
                 try {
-                    if (newSingleSourceApplicableTestResult != null && !newSingleSourceApplicableTestResult.isEmpty()) {
-                        if (!newSingleSourceApplicableTestResult.get(index)) {
-                            return s;
-                        }
+                    if (hasSingleSourceApplicableTest && singleSourceApplicableTestResultRef.containsKey(s.getId())
+                            && !singleSourceApplicableTestResultRef.get(s.getId())) {
+                        return s;
                     }
 
                     Duration duration = Duration.ofNanos(System.nanoTime() - startTime);
@@ -323,10 +324,49 @@ default <S extends SourceFile> List<S> scheduleVisit(RecipeRunStats runStats,
         // of a type that is in the original set of source files (e.g. only XML files are given, and the
         // recipe generates Java code).
         List<SourceFile> afterWidened;
+        final Map<UUID, Boolean> lastSingleSourceApplicableTestResult = singleSourceApplicableTestResult;
+        final Map<UUID, Boolean> newSingleSourceApplicableTestResult = new HashMap<>();
         try {
             long ownVisitStartTime = System.nanoTime();
+
+            if (hasSingleSourceApplicableTest) {
+                boolean anyFilePassedSingleApplicableTest = false;
+                for (Boolean b : singleSourceApplicableTestResult.values()) {
+                    if (b) {
+                        anyFilePassedSingleApplicableTest = true;
+                        break;
+                    }
+                }
+                if (!anyFilePassedSingleApplicableTest) {
+                    return after;
+                }
+            }
+
             //noinspection unchecked
             afterWidened = recipe.visit((List<SourceFile>) after, ctx);
+
+            if (hasSingleSourceApplicableTest) {
+                // update single source applicability test results
+                Map<UUID, SourceFile> originalMap = new HashMap<>(after.size());
+                for (SourceFile file : after) {
+                    originalMap.put(file.getId(), file);
+                }
+
+                afterWidened = ListUtils.map(afterWidened, s -> {
+                    Boolean singleSourceTestResult = lastSingleSourceApplicableTestResult.get(s.getId());
+                    if (singleSourceTestResult != null) {
+                        newSingleSourceApplicableTestResult.put(s.getId(), singleSourceTestResult);
+                        if (!singleSourceTestResult) {
+                            return originalMap.get(s.getId());
+                        }
+                    } else {
+                        // It's a newly generated file
+                        newSingleSourceApplicableTestResult.put(s.getId(), true);
+                    }
+                    return s;
+                });
+            }
+
             runStats.ownVisit.addAndGet(System.nanoTime() - ownVisitStartTime);
         } catch (Throwable t) {
             return handleUncaughtException(recipeStack, recipeThatAddedOrDeletedSourceFile, before, ctx, recipe, t);
@@ -387,11 +427,12 @@ default <S extends SourceFile> List<S> scheduleVisit(RecipeRunStats runStats,
                 runStats.getCalled().add(nextStats);
             }
 
+            Map<UUID, Boolean> newMap = new HashMap<>(newSingleSourceApplicableTestResult);
             afterWidened = scheduleVisit(requireNonNull(nextStats),
-                    nextStack,
-                    afterWidened,
-                    singleSourceApplicableTestResult,
-                    ctx, recipeThatAddedOrDeletedSourceFile);
+                nextStack,
+                afterWidened,
+                newMap,
+                ctx, recipeThatAddedOrDeletedSourceFile);
         }
 
         long totalTime = System.nanoTime() - startTime;