From d7f1ff5bfb32732e98c09f4ec08c8131104523bd Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Mon, 4 May 2026 12:45:48 -0400 Subject: [PATCH 1/3] Wrap recipe errors with RecipeError to attribute failures to a nested recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Consumer` returned by `ExecutionContext#getOnError()` previously received the raw throwable from a failing recipe, leaving downstream tooling no way to tell which nested recipe (in a large declarative recipe with hundreds or thousands of children) actually produced the failure. Introduce `RecipeError` — a `RuntimeException` carrying the recipe path (root → leaf) and source path — and wrap throwables at the single chokepoint `RecipeRunCycle.handleError` and the run-timeout site before invoking the `onError` consumer. The `SourcesFileErrors` data table continues to receive the original sanitized stack trace, so attribution there is unchanged. Update `RewriteTest`'s default consumer to unwrap the new `RecipeError` before its `instanceof RecipeRunException` check. --- .../java/org/openrewrite/RecipeError.java | 60 +++++++++++++++ .../scheduling/RecipeRunCycle.java | 28 ++++--- .../org/openrewrite/RecipeSchedulerTest.java | 73 +++++++++++++++++++ .../org/openrewrite/test/RewriteTest.java | 5 +- 4 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 rewrite-core/src/main/java/org/openrewrite/RecipeError.java diff --git a/rewrite-core/src/main/java/org/openrewrite/RecipeError.java b/rewrite-core/src/main/java/org/openrewrite/RecipeError.java new file mode 100644 index 00000000000..f7f8f70251e --- /dev/null +++ b/rewrite-core/src/main/java/org/openrewrite/RecipeError.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * 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; + +import lombok.Getter; +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.List; + +/** + * Wraps an exception thrown by a recipe so that {@link ExecutionContext#getOnError()} + * consumers can attribute the failure to the specific nested recipe and source file + * that produced it. This is especially useful for large declarative recipes (e.g. + * thousands of nested recipes) where the underlying stack trace alone does not + * reveal which recipe ran into trouble. + */ +@Getter +public class RecipeError extends RuntimeException { + + /** + * The names of the recipes from root to leaf at the time of failure. + * The last element is the recipe that directly threw. + */ + private final List recipeStack; + + /** + * The path of the source file being processed when the error occurred, + * or {@code null} when the error was raised outside of a per-source context + * (for example, during scanning recipe generation). + */ + private final @Nullable String sourcePath; + + public RecipeError(List recipeStack, @Nullable String sourcePath, Throwable cause) { + super(cause); + this.recipeStack = Collections.unmodifiableList(recipeStack); + this.sourcePath = sourcePath; + } + + /** + * @return the leaf recipe name (the recipe that directly threw), or + * {@code "unknown"} if the recipe stack is empty. + */ + public String getRecipeName() { + return recipeStack.isEmpty() ? "unknown" : recipeStack.get(recipeStack.size() - 1); + } +} diff --git a/rewrite-core/src/main/java/org/openrewrite/scheduling/RecipeRunCycle.java b/rewrite-core/src/main/java/org/openrewrite/scheduling/RecipeRunCycle.java index cff19046873..74f1406c71c 100644 --- a/rewrite-core/src/main/java/org/openrewrite/scheduling/RecipeRunCycle.java +++ b/rewrite-core/src/main/java/org/openrewrite/scheduling/RecipeRunCycle.java @@ -151,7 +151,7 @@ public LSS scanSources(LSS sourceSet) { return source; }); } catch (Throwable t) { - after = handleError(recipe, source, after, t); + after = handleError(recipeStack, source, after, t); // We don't normally consider anything the scanning phase does to be a change // But this simplifies error reporting so that exceptions can all be handled the same assert after != null; @@ -183,7 +183,7 @@ private void flushScanBatch(BatchState batch, SourceFile source) { batch.rpc.batchVisit(source, ctx, rootCursor, batch.items); } catch (Throwable t) { if (!batch.recipeStacks.isEmpty()) { - handleError(batch.recipeStacks.get(0).peek(), source, source, t); + handleError(batch.recipeStacks.get(0), source, source, t); } } @@ -211,7 +211,7 @@ public LSS generateSources(LSS sourceSet) { return source; }); } catch (Throwable t) { - handleError(recipe, source, source, t); + handleError(recipeStack, source, source, t); } } } @@ -243,7 +243,7 @@ public LSS generateSources(LSS sourceSet) { madeChangesInThisCycle.add(recipe); } } catch (Throwable t) { - handleError(recipe, new Quark(Tree.randomId(), Paths.get("error during generation"), Markers.EMPTY, null, null), null, t); + handleError(recipeStack, new Quark(Tree.randomId(), Paths.get("error during generation"), Markers.EMPTY, null, null), null, t); } } return acc; @@ -316,7 +316,7 @@ void clear() { if (duration.compareTo(ctx.getMessage(ExecutionContext.RUN_TIMEOUT, Duration.ofMinutes(4))) > 0) { if (thrownErrorOnTimeout.compareAndSet(false, true)) { RecipeTimeoutException t = new RecipeTimeoutException(recipe); - ctx.getOnError().accept(t); + ctx.getOnError().accept(wrapForAttribution(recipeStack, src.getSourcePath().toString(), t)); ctx.getOnTimeout().accept(t, ctx); } return src; @@ -382,7 +382,7 @@ void clear() { if (isInBatch) { batch.clear(); } - after = handleError(recipe, src, after, t); + after = handleError(recipeStack, src, after, t); } if (after != null && after != src) { after = addRecipesThatMadeChanges(recipeStack, after); @@ -417,7 +417,7 @@ void clear() { if (!(t instanceof RecipeRunException)) { source = Markup.error(source, t); } - source = handleError(batch.recipeStacks.get(0).peek(), originalBefore, source, t); + source = handleError(batch.recipeStacks.get(0), originalBefore, source, t); if (source != null && source != beforeError) { source = addRecipesThatMadeChanges(batch.recipeStacks.get(0), source); } @@ -720,9 +720,9 @@ private void recordSourceFileResult(@Nullable String beforePath, @Nullable Strin recordSourceFileResult(beforePath, afterPath, recipeStack.subList(0, recipeStack.size() - 1), ctx); } - private @Nullable SourceFile handleError(Recipe recipe, SourceFile sourceFile, @Nullable SourceFile after, + private @Nullable SourceFile handleError(List recipeStack, SourceFile sourceFile, @Nullable SourceFile after, Throwable t) { - ctx.getOnError().accept(t); + ctx.getOnError().accept(wrapForAttribution(recipeStack, sourceFile.getSourcePath().toString(), t)); if (t instanceof RecipeRunException && after != null) { try { @@ -738,13 +738,21 @@ private void recordSourceFileResult(@Nullable String beforePath, @Nullable Strin // This is so the error is associated with the original source file, and its original source path. errorsTable.insertRow(ctx, new SourcesFileErrors.Row( sourceFile.getSourcePath().toString(), - recipe.getName(), + recipeStack.get(recipeStack.size() - 1).getName(), ExceptionUtils.sanitizeStackTrace(t, RecipeScheduler.class) )); return after; } + private static RecipeError wrapForAttribution(List recipeStack, @Nullable String sourcePath, Throwable t) { + List recipePath = new ArrayList<>(recipeStack.size()); + for (Recipe r : recipeStack) { + recipePath.add(r.getName()); + } + return new RecipeError(recipePath, sourcePath, t); + } + private static S addRecipesThatMadeChanges(List recipeStack, S afterFile) { return afterFile.withMarkers(afterFile.getMarkers().computeByType( RecipesThatMadeChanges.create(recipeStack), diff --git a/rewrite-core/src/test/java/org/openrewrite/RecipeSchedulerTest.java b/rewrite-core/src/test/java/org/openrewrite/RecipeSchedulerTest.java index ff13bafee82..18a12bf1e12 100644 --- a/rewrite-core/src/test/java/org/openrewrite/RecipeSchedulerTest.java +++ b/rewrite-core/src/test/java/org/openrewrite/RecipeSchedulerTest.java @@ -83,6 +83,79 @@ void exceptionsCauseResult() { ); } + @Test + void onErrorReceivesRecipeErrorWithLeafRecipeAndSourcePath() { + List captured = new java.util.ArrayList<>(); + InMemoryExecutionContext ctx = new InMemoryExecutionContext(t -> { + if (t instanceof RecipeError) { + captured.add((RecipeError) t); + } + }); + rewriteRun( + spec -> spec.executionContext(ctx).recipe(new BoomRecipe()), + text("hello", "~~(boom)~~>hello") + ); + assertThat(captured).isNotEmpty().allSatisfy(err -> { + assertThat(err.getRecipeStack()).containsExactly("org.openrewrite.BoomRecipe"); + assertThat(err.getSourcePath()).isEqualTo("file.txt"); + // Cause is RecipeRunException (the visitor's throw site wrapper) which in turn wraps BoomException. + assertThat(err.getCause()).isInstanceOf(RecipeRunException.class); + assertThat(err.getCause().getCause()).isInstanceOf(BoomException.class); + }); + } + + @Test + void onErrorReceivesRecipeErrorWithFullStackForNestedRecipe() { + List captured = new java.util.ArrayList<>(); + InMemoryExecutionContext ctx = new InMemoryExecutionContext(t -> { + if (t instanceof RecipeError) { + captured.add((RecipeError) t); + } + }); + var parent = new DeclarativeRecipe( + "io.example.parent", + "Parent recipe", + "Parent recipe wrapping a boom child.", + emptySet(), + null, + URI.create("dummy:recipe.yml"), + false, + emptyList() + ); + parent.addUninitialized(new BoomRecipe()); + parent.initialize(emptyList()); + rewriteRun( + spec -> spec.executionContext(ctx).recipe(parent), + text("hello", "~~(boom)~~>hello") + ); + assertThat(captured).isNotEmpty().allSatisfy(err -> { + assertThat(err.getRecipeStack()) + .containsExactly("io.example.parent", "org.openrewrite.BoomRecipe"); + assertThat(err.getRecipeName()).isEqualTo("org.openrewrite.BoomRecipe"); + assertThat(err.getSourcePath()).isEqualTo("file.txt"); + }); + } + + @Test + void onErrorWrapsErrorsThrownDuringGenerate() { + List captured = new java.util.ArrayList<>(); + InMemoryExecutionContext ctx = new InMemoryExecutionContext(t -> { + if (t instanceof RecipeError) { + captured.add((RecipeError) t); + } + }); + rewriteRun( + spec -> spec.executionContext(ctx).recipe(new BoomGenerateRecipe(false)) + ); + assertThat(captured) + .singleElement() + .satisfies(err -> { + assertThat(err.getRecipeStack()).containsExactly("org.openrewrite.BoomGenerateRecipe"); + assertThat(err.getSourcePath()).isEqualTo("error during generation"); + assertThat(err.getCause()).isInstanceOf(BoomException.class); + }); + } + @Test void exceptionDuringGenerate() { rewriteRun( diff --git a/rewrite-test/src/main/java/org/openrewrite/test/RewriteTest.java b/rewrite-test/src/main/java/org/openrewrite/test/RewriteTest.java index e68ec8542b2..c34bc3d1721 100644 --- a/rewrite-test/src/main/java/org/openrewrite/test/RewriteTest.java +++ b/rewrite-test/src/main/java/org/openrewrite/test/RewriteTest.java @@ -704,8 +704,9 @@ default void rewriteRun(SourceSpec... sources) { default ExecutionContext defaultExecutionContext(SourceSpec[] sourceSpecs) { InMemoryExecutionContext ctx = new InMemoryExecutionContext(t -> { - if (t instanceof RecipeRunException) { - fail("Failed to run recipe at " + ((RecipeRunException) t).getCursor(), t); + Throwable underlying = t instanceof RecipeError ? t.getCause() : t; + if (underlying instanceof RecipeRunException) { + fail("Failed to run recipe at " + ((RecipeRunException) underlying).getCursor(), t); } fail("Failed to parse sources or run recipe", t); }); From 8b3259cffbab722509c01f851132bcc53c8c478a Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Mon, 4 May 2026 13:01:29 -0400 Subject: [PATCH 2/3] Use stream over manual loop in wrapForAttribution --- .../java/org/openrewrite/scheduling/RecipeRunCycle.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/rewrite-core/src/main/java/org/openrewrite/scheduling/RecipeRunCycle.java b/rewrite-core/src/main/java/org/openrewrite/scheduling/RecipeRunCycle.java index 74f1406c71c..db8e7b4dc7c 100644 --- a/rewrite-core/src/main/java/org/openrewrite/scheduling/RecipeRunCycle.java +++ b/rewrite-core/src/main/java/org/openrewrite/scheduling/RecipeRunCycle.java @@ -50,6 +50,7 @@ import java.util.function.UnaryOperator; import static java.util.Collections.*; +import static java.util.stream.Collectors.toList; import static org.openrewrite.ExecutionContext.SCANNING_MUTATION_VALIDATION; import static org.openrewrite.Recipe.PANIC; @@ -746,11 +747,7 @@ private void recordSourceFileResult(@Nullable String beforePath, @Nullable Strin } private static RecipeError wrapForAttribution(List recipeStack, @Nullable String sourcePath, Throwable t) { - List recipePath = new ArrayList<>(recipeStack.size()); - for (Recipe r : recipeStack) { - recipePath.add(r.getName()); - } - return new RecipeError(recipePath, sourcePath, t); + return new RecipeError(recipeStack.stream().map(Recipe::getName).collect(toList()), sourcePath, t); } private static S addRecipesThatMadeChanges(List recipeStack, S afterFile) { From b168e9d97a4381d4248e65e432e57d0a227eb14a Mon Sep 17 00:00:00 2001 From: Steve Elliott Date: Mon, 4 May 2026 14:54:13 -0400 Subject: [PATCH 3/3] Pass unwrapped throwable to fail() so AssertionError cause is RecipeRunException RewriteTest.defaultExecutionContext was passing the outer RecipeError to fail(), which made the resulting AssertionError's cause RecipeError instead of the underlying RecipeRunException. This regressed RepeatTest.repeatValidatesCursorIsPassed, which asserts that the cause is a RecipeRunException. --- .../src/main/java/org/openrewrite/test/RewriteTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rewrite-test/src/main/java/org/openrewrite/test/RewriteTest.java b/rewrite-test/src/main/java/org/openrewrite/test/RewriteTest.java index c34bc3d1721..c40881c2634 100644 --- a/rewrite-test/src/main/java/org/openrewrite/test/RewriteTest.java +++ b/rewrite-test/src/main/java/org/openrewrite/test/RewriteTest.java @@ -706,9 +706,9 @@ default ExecutionContext defaultExecutionContext(SourceSpec[] sourceSpecs) { InMemoryExecutionContext ctx = new InMemoryExecutionContext(t -> { Throwable underlying = t instanceof RecipeError ? t.getCause() : t; if (underlying instanceof RecipeRunException) { - fail("Failed to run recipe at " + ((RecipeRunException) underlying).getCursor(), t); + fail("Failed to run recipe at " + ((RecipeRunException) underlying).getCursor(), underlying); } - fail("Failed to parse sources or run recipe", t); + fail("Failed to parse sources or run recipe", underlying); }); for (Consumer customizer : defaultExecutionContextCustomizers.values()) { customizer.accept(ctx);