From 31065f123e9f2793b155ceb7753bbaf3e0d7127d Mon Sep 17 00:00:00 2001 From: Sebastian Spaink Date: Mon, 20 Jul 2026 14:58:34 -0500 Subject: [PATCH] cmd/check: report wrapped structured errors individually (#8912) Fixes #3663 In JSON mode, `opa check -b` collapsed all compilation errors into one opaque string, unlike non-bundle mode which lists each with its code and location. The bundle loader wraps errors as `fmt.Errorf("bundle %s: %w", ...)`, and NewOutputErrors default case stringified the wrapper instead of the structured ast.Errors inside it. The default case now unwraps and recurses, keeping the wrapper's message only when unwrapping reveals nothing structured. Signed-off-by: Sebastian Spaink --- internal/presentation/presentation.go | 12 ++++++ internal/presentation/presentation_test.go | 44 ++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/internal/presentation/presentation.go b/internal/presentation/presentation.go index e90d1ba583..fe940e1740 100644 --- a/internal/presentation/presentation.go +++ b/internal/presentation/presentation.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "slices" "sort" "strconv" "strings" @@ -192,6 +193,17 @@ func NewOutputErrors(err error) []OutputError { } } default: + // Unwrap wrapped errors (e.g. the bundle loader's + // fmt.Errorf("bundle %s: %w", ...)) to report the structured errors + // they hide individually rather than as one opaque string (#3663). + // Keep the wrapper's message if unwrapping reveals nothing structured. + hasStructuredCode := func(e OutputError) bool { return e.Code != "" } + if inner := errors.Unwrap(err); inner != nil { + if unwrapped := NewOutputErrors(inner); slices.ContainsFunc(unwrapped, hasStructuredCode) { + return unwrapped + } + } + // Any errors which don't have a structure we know about // are converted to their string representation only. errs = []OutputError{{ diff --git a/internal/presentation/presentation_test.go b/internal/presentation/presentation_test.go index a4caa96920..ff6bcf398f 100644 --- a/internal/presentation/presentation_test.go +++ b/internal/presentation/presentation_test.go @@ -123,6 +123,50 @@ func TestOutputJSONErrorStructuredASTErr(t *testing.T) { validateJSONOutput(t, err, expected) } +func TestOutputJSONErrorWrapped(t *testing.T) { + tests := map[string]struct { + err error + expected string + }{ + "structured errors are unwrapped and listed individually": { + err: fmt.Errorf("bundle /some/path: %w", ast.Errors{ + &ast.Error{Code: "rego_parse_error", Message: "first"}, + &ast.Error{Code: "rego_parse_error", Message: "second"}, + }), + expected: `{ + "errors": [ + { + "message": "first", + "code": "rego_parse_error" + }, + { + "message": "second", + "code": "rego_parse_error" + } + ] +} +`, + }, + "plain error keeps the wrapper's message": { + err: fmt.Errorf("failed to load config: %w", errors.New("file not found")), + expected: `{ + "errors": [ + { + "message": "failed to load config: file not found" + } + ] +} +`, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + validateJSONOutput(t, tc.err, tc.expected) + }) + } +} + func TestOutputJSONErrorStructuredStorageErr(t *testing.T) { store := inmem.New() txn := storage.NewTransactionOrDie(t.Context(), store)