Skip to content

Commit ace0569

Browse files
authored
During declarative recipe loading filter out duplicate copies of singleton recipes. (#7468)
I had proposed something very similar to this before, here: #6560 That didn't get merged because it felt too magical / special case for DeclarativeRecipe to deduplicate as a silent implementation detail. Instead of doing this the first time we decided on creating Singleton. But with Singleton alone I cannot find a way to guard the scanning phase without breaking ScanningRecipe.generate(). I tried several things. But a common usage for scanning recipes is to verify whether a file already exists, if I skip those then generate() recipes will create files they shouldn't because their scanner was skipped by Singleton. So I'm resurrecting the declarative recipe duplicate loading skip. But this time I'm keying off the presence of Singleton, which explicitly opts-in a recipe to desiring this behavior.
1 parent a922f22 commit ace0569

2 files changed

Lines changed: 184 additions & 3 deletions

File tree

rewrite-core/src/main/java/org/openrewrite/config/DeclarativeRecipe.java

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import org.intellij.lang.annotations.Language;
2222
import org.jspecify.annotations.Nullable;
2323
import org.openrewrite.*;
24+
import org.openrewrite.internal.ListUtils;
2425

2526
import java.net.URI;
2627
import java.time.Duration;
@@ -503,17 +504,87 @@ public List<Recipe> getPreconditions() {
503504

504505
@Override
505506
public final List<Recipe> getRecipeList() {
507+
List<Recipe> filtered = deduplicateSingletonsRecursively(recipeList, new HashSet<>());
508+
506509
if (preconditions.isEmpty()) {
507-
return recipeList;
510+
return filtered;
508511
}
509512

510513
PreconditionBellwether bellwether = new PreconditionBellwether(this);
511-
List<Recipe> recipeListWithBellwether = new ArrayList<>(recipeList.size() + 1);
514+
List<Recipe> recipeListWithBellwether = new ArrayList<>(filtered.size() + 1);
512515
recipeListWithBellwether.add(bellwether);
513-
recipeListWithBellwether.addAll(decorateWithPreconditionBellwether(bellwether, recipeList));
516+
recipeListWithBellwether.addAll(decorateWithPreconditionBellwether(bellwether, filtered));
514517
return recipeListWithBellwether;
515518
}
516519

520+
/**
521+
* Walk {@code recipes} and every {@link DeclarativeRecipe} reachable through it, dropping
522+
* later occurrences of any DeclarativeRecipe (by name) whose preconditions include
523+
* {@link Singleton}. The {@code seen} set is threaded through the entire traversal so a
524+
* Singleton-gated recipe that appears under one branch is also filtered from sibling or
525+
* nested branches. The {@code Singleton} precondition would make the later occurrences
526+
* no-op at runtime; removing them from the list avoids scheduling them at all.
527+
*/
528+
private static List<Recipe> deduplicateSingletonsRecursively(List<Recipe> recipes, Set<String> seen) {
529+
return ListUtils.map(recipes, recipe -> {
530+
if (recipe instanceof DeclarativeRecipe) {
531+
DeclarativeRecipe dr = (DeclarativeRecipe) recipe;
532+
if (hasSingletonPrecondition(dr) && !seen.add(dr.getName())) {
533+
//noinspection DataFlowIssue
534+
return null;
535+
}
536+
return dr.withDeduplicatedChildren(seen);
537+
}
538+
return recipe;
539+
});
540+
}
541+
542+
/**
543+
* Return a view of this recipe whose {@code recipeList} has been recursively filtered to
544+
* remove Singleton-gated duplicates using the shared {@code seen} set. If nothing changes
545+
* the original instance is returned; otherwise a copy is produced so the original tree is
546+
* left untouched.
547+
*/
548+
DeclarativeRecipe withDeduplicatedChildren(Set<String> seen) {
549+
List<Recipe> deduplicated = deduplicateSingletonsRecursively(recipeList, seen);
550+
if (deduplicated == recipeList) {
551+
return this;
552+
}
553+
return copyWithRecipeList(this, deduplicated);
554+
}
555+
556+
/**
557+
* Factory used by {@link #withDeduplicatedChildren(Set)} to produce an otherwise identical
558+
* recipe whose {@code recipeList} has been filtered. Shares immutable (or effectively
559+
* immutable) state — {@code preconditions}, {@code validation}, {@code initValidation} —
560+
* with {@code source} and leaves {@code uninitializedRecipes} empty so {@link #validate()}
561+
* doesn't reject the filtered list as "uninitialized". The new instance's
562+
* {@link ScanningRecipe} field initializer gives it a fresh {@code recipeAccMessage}.
563+
* <p>
564+
* Implemented as a static factory rather than an additional constructor so that Jackson
565+
* can still unambiguously pick the Lombok-generated required-args constructor during
566+
* recipe deserialization.
567+
*/
568+
private static DeclarativeRecipe copyWithRecipeList(DeclarativeRecipe source, List<Recipe> recipeList) {
569+
DeclarativeRecipe copy = new DeclarativeRecipe(source.name, source.displayName,
570+
source.description, source.tags, source.estimatedEffortPerOccurrence,
571+
source.source, source.causesAnotherCycle, source.maintainers);
572+
copy.recipeList = recipeList;
573+
copy.preconditions = source.preconditions;
574+
copy.validation = source.validation;
575+
copy.initValidation = source.initValidation;
576+
return copy;
577+
}
578+
579+
private static boolean hasSingletonPrecondition(DeclarativeRecipe recipe) {
580+
for (Recipe precondition : recipe.getPreconditions()) {
581+
if (precondition instanceof Singleton) {
582+
return true;
583+
}
584+
}
585+
return false;
586+
}
587+
517588
@SuppressWarnings({"rawtypes", "unchecked"})
518589
private TreeVisitor<?, ExecutionContext> orVisitors(Recipe recipe, Cursor rootCursor, ExecutionContext ctx) {
519590
List<TreeVisitor<?, ExecutionContext>> conditions = new ArrayList<>();

rewrite-core/src/test/java/org/openrewrite/SingletonTest.java

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
import java.nio.charset.StandardCharsets;
3030
import java.util.List;
3131
import java.util.Properties;
32+
import java.util.concurrent.atomic.AtomicInteger;
3233

34+
import static org.assertj.core.api.Assertions.assertThat;
3335
import static org.openrewrite.Singleton.singleton;
3436
import static org.openrewrite.test.SourceSpecs.text;
3537

@@ -240,4 +242,112 @@ void yamlRecipeWithSingletonPreconditionAcrossDifferentYamlFiles() {
240242
)
241243
);
242244
}
245+
246+
/**
247+
* A scanning recipe whose scanner increments a static counter for each visited file.
248+
* Used to prove that when it is used behind a Singleton precondition, the scanner
249+
* runs once per file for the first recipe instance and is skipped entirely for later
250+
* equivalent instances.
251+
*/
252+
@EqualsAndHashCode(callSuper = false)
253+
@Value
254+
static class CountingScanRecipe extends ScanningRecipe<AtomicInteger> {
255+
static final AtomicInteger SCAN_COUNT = new AtomicInteger();
256+
257+
@Override
258+
public String getDisplayName() {
259+
return "Counting scan recipe";
260+
}
261+
262+
@Override
263+
public String getDescription() {
264+
return "Tracks how many files its scanner visits via a shared static counter.";
265+
}
266+
267+
@Override
268+
public AtomicInteger getInitialValue(ExecutionContext ctx) {
269+
return SCAN_COUNT;
270+
}
271+
272+
@Override
273+
public TreeVisitor<?, ExecutionContext> getScanner(AtomicInteger acc) {
274+
return new TreeVisitor<>() {
275+
@Override
276+
public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) {
277+
SCAN_COUNT.incrementAndGet();
278+
return tree;
279+
}
280+
};
281+
}
282+
}
283+
284+
@Test
285+
void singletonPreconditionDeduplicatesAcrossNestingLevels() {
286+
CountingScanRecipe.SCAN_COUNT.set(0);
287+
288+
// ChildWithCountingScanner appears twice: once at the top level of ParentRecipe, and once
289+
// nested inside NestedWrapper. Recursive deduplication should keep the first occurrence
290+
// and drop the nested one.
291+
String childYaml = """
292+
---
293+
type: specs.openrewrite.org/v1beta/recipe
294+
name: org.openrewrite.ChildWithCountingScanner
295+
displayName: Child recipe with counting scanner behind Singleton
296+
description: Uses Singleton precondition so scanning is skipped for duplicate instances.
297+
preconditions:
298+
- org.openrewrite.Singleton
299+
recipeList:
300+
- org.openrewrite.SingletonTest$CountingScanRecipe
301+
""";
302+
303+
String nestedWrapperYaml = """
304+
---
305+
type: specs.openrewrite.org/v1beta/recipe
306+
name: org.openrewrite.NestedWrapper
307+
displayName: Wrapper that also pulls in the counting child
308+
description: Embeds the counting child under a layer of nesting.
309+
recipeList:
310+
- org.openrewrite.ChildWithCountingScanner
311+
""";
312+
313+
String parentYaml = """
314+
---
315+
type: specs.openrewrite.org/v1beta/recipe
316+
name: org.openrewrite.ParentRecipe
317+
displayName: Parent recipe that references the counting child at two different depths
318+
description: Forces the Singleton-gated child to appear at different levels of the tree.
319+
recipeList:
320+
- org.openrewrite.ChildWithCountingScanner
321+
- org.openrewrite.NestedWrapper
322+
""";
323+
324+
Recipe recipe = Environment.builder()
325+
.load(new YamlResourceLoader(
326+
new ByteArrayInputStream(childYaml.getBytes(StandardCharsets.UTF_8)),
327+
URI.create("child.yml"),
328+
new Properties()))
329+
.load(new YamlResourceLoader(
330+
new ByteArrayInputStream(nestedWrapperYaml.getBytes(StandardCharsets.UTF_8)),
331+
URI.create("nested-wrapper.yml"),
332+
new Properties()))
333+
.load(new YamlResourceLoader(
334+
new ByteArrayInputStream(parentYaml.getBytes(StandardCharsets.UTF_8)),
335+
URI.create("parent.yml"),
336+
new Properties()))
337+
.build()
338+
.activateRecipes("org.openrewrite.ParentRecipe");
339+
340+
rewriteRun(
341+
spec -> spec
342+
.cycles(1)
343+
.expectedCyclesThatMakeChanges(0)
344+
.recipe(recipe)
345+
.validateRecipeSerialization(false)
346+
.afterRecipe(run -> assertThat(CountingScanRecipe.SCAN_COUNT.get())
347+
.as("scanner should run once per file exactly once across the whole tree; nested occurrence must be filtered by recursive singleton deduplication")
348+
.isEqualTo(2)),
349+
text("one", spec -> spec.path("a.txt")),
350+
text("two", spec -> spec.path("b.txt"))
351+
);
352+
}
243353
}

0 commit comments

Comments
 (0)