fix(i18n): wrap parse error cause (#1319)

resolvicomai created

## What?
- Wrap the JSON parser source error with `%w` instead of rendering it
with `%v`.
- Add regression coverage that preserves both `ErrParseFailed` and the
original `json.SyntaxError` in the error chain.

Closes #1050

## Why?
The i18n parser already wraps `ErrParseFailed`, but the underlying JSON
parse error was converted to text. That made `errors.As` unable to
recover the original cause from callers that need to inspect it.

Change summary

i18n/parser.go      |  2 +-
i18n/parser_test.go | 23 +++++++++++++++++++++++
2 files changed, 24 insertions(+), 1 deletion(-)

Detailed changes

i18n/parser.go 🔗

@@ -15,7 +15,7 @@ type TranslationFile struct {
 func ParseJSON(data []byte) (MessageMap, error) {
 	var file TranslationFile
 	if err := json.Unmarshal(data, &file); err != nil {
-		return nil, fmt.Errorf("%w: %v", ErrParseFailed, err)
+		return nil, fmt.Errorf("%w: %w", ErrParseFailed, err)
 	}
 
 	messages := make(MessageMap)

i18n/parser_test.go 🔗

@@ -0,0 +1,23 @@
+package i18n
+
+import (
+	"encoding/json"
+	"errors"
+	"testing"
+)
+
+func TestParseJSONWrapsSyntaxError(t *testing.T) {
+	_, err := ParseJSON([]byte(`{"language":"en","messages":`))
+	if err == nil {
+		t.Fatal("ParseJSON() error = nil, want parse error")
+	}
+
+	if !errors.Is(err, ErrParseFailed) {
+		t.Fatalf("ParseJSON() error = %v, want ErrParseFailed in chain", err)
+	}
+
+	var syntaxErr *json.SyntaxError
+	if !errors.As(err, &syntaxErr) {
+		t.Fatalf("ParseJSON() error = %v, want json.SyntaxError in chain", err)
+	}
+}