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 <sebastianspaink@gmail.com>
This commit is contained in:
Sebastian Spaink
2026-07-20 14:58:34 -05:00
committed by GitHub
parent 3368497a96
commit 31065f123e
2 changed files with 56 additions and 0 deletions
+12
View File
@@ -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{{
@@ -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)