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 0000000000..f7f8f70251 --- /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 cff1904687..db8e7b4dc7 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; @@ -151,7 +152,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 +184,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 +212,7 @@ public LSS generateSources(LSS sourceSet) { return source; }); } catch (Throwable t) { - handleError(recipe, source, source, t); + handleError(recipeStack, source, source, t); } } } @@ -243,7 +244,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 +317,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 +383,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 +418,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 +721,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 +739,17 @@ 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) { + return new RecipeError(recipeStack.stream().map(Recipe::getName).collect(toList()), 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 ff13bafee8..18a12bf1e1 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 e68ec8542b..c40881c263 100644 --- a/rewrite-test/src/main/java/org/openrewrite/test/RewriteTest.java +++ b/rewrite-test/src/main/java/org/openrewrite/test/RewriteTest.java @@ -704,10 +704,11 @@ 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(), 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);