diff --git a/CHANGELOG.md b/CHANGELOG.md index e70debe..35141b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ on the default branch using [git-cliff](https://git-cliff.org/). ### Bug Fixes +- **test:** Restore pointeredResult passthrough test syntax [c3289e5](https://github.com/platformrelay/kollect-render/commit/c3289e5f272b3ead96d33b89bcb6dde0c234b6de) + - **sonar:** Exclude tests from sources so CPD clears [094fc8e](https://github.com/platformrelay/kollect-render/commit/094fc8ee9a97f1647fee02d29d3036b25930cd4a) - **ci:** Disable Codecov coverage file search [1107268](https://github.com/platformrelay/kollect-render/commit/110726825c04ae240bc2585b26db5c387ec61885) diff --git a/go.mod b/go.mod index 5e45d1c..20974e7 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/platformrelay/kollect-render go 1.26.5 require ( - github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 + golang.org/x/mod v0.37.0 + golang.org/x/text v0.39.0 gopkg.in/yaml.v3 v3.0.1 ) - -require golang.org/x/mod v0.29.0 // indirect diff --git a/go.sum b/go.sum index 0d889c0..a7b0537 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ -github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= -github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/hack/test/jsonschema_v6_test.sh b/hack/test/jsonschema_v6_test.sh new file mode 100755 index 0000000..c6f51ad --- /dev/null +++ b/hack/test/jsonschema_v6_test.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# E6-S03: go.mod must require jsonschema/v6 only (no leftover v5). +# Run: bash hack/test/jsonschema_v6_test.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +GOMOD="${ROOT}/go.mod" + +fail=0 +ok() { echo "ok: $*"; } +bad() { echo "FAIL: $*" >&2; fail=1; } + +[[ -f "${GOMOD}" ]] || { echo "FAIL: go.mod missing" >&2; exit 1; } + +if grep -Eq 'github.com/santhosh-tekuri/jsonschema/v6 v6\.' "${GOMOD}"; then + ok "go.mod requires jsonschema/v6" +else + bad "go.mod does not require github.com/santhosh-tekuri/jsonschema/v6" +fi + +if grep -Eq 'github.com/santhosh-tekuri/jsonschema/v5' "${GOMOD}"; then + bad "go.mod still mentions jsonschema/v5 — dual-require is the broken Renovate shape" +else + ok "go.mod has no jsonschema/v5" +fi + +if [[ "${fail}" -ne 0 ]]; then + echo "jsonschema_v6_test: RED" >&2 + exit 1 +fi +echo "jsonschema_v6_test: GREEN" diff --git a/internal/validate/v6_pointer_test.go b/internal/validate/v6_pointer_test.go new file mode 100644 index 0000000..0cab1c0 --- /dev/null +++ b/internal/validate/v6_pointer_test.go @@ -0,0 +1,104 @@ +package validate + +import ( + "errors" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/santhosh-tekuri/jsonschema/v6/kind" +) + +func TestValidationMessageNil(t *testing.T) { + t.Parallel() + if validationMessage(nil) != "validation failed" { + t.Fatal("nil error") + } +} + +func TestPointeredResultPassesThroughNonValidationError(t *testing.T) { + t.Parallel() + in := errors.New("not a schema error") + if got := pointeredResult(in); !errors.Is(got, in) { + t.Fatalf("got %v, want passthrough", got) + } +} + +func TestCollectLeavesNilIsNoop(t *testing.T) { + t.Parallel() + res := &Result{} + collectLeaves(nil, res) + if len(res.Errors) != 0 { + t.Fatalf("errors = %+v", res.Errors) + } +} + +func TestCollectLeavesNilKindUsesErrorString(t *testing.T) { + t.Parallel() + res := &Result{} + collectLeaves(&jsonschema.ValidationError{ErrorKind: nil}, res) + if len(res.Errors) != 1 || res.Errors[0].Message != "validation failed" { + t.Fatalf("errors = %+v", res.Errors) + } +} + +func TestPropertyFromKindEdges(t *testing.T) { + t.Parallel() + if _, ok := propertyFromKind(nil); ok { + t.Fatal("nil kind should not yield a property") + } + if _, ok := propertyFromKind(&kind.Required{Missing: []string{"a", "b"}}); ok { + t.Fatal("multi-missing required should not pick a single property") + } + if _, ok := propertyFromKind(&kind.AdditionalProperties{Properties: []string{"a", "b"}}); ok { + t.Fatal("multi additional properties should not pick a single property") + } + if _, ok := propertyFromKind(&kind.Type{Got: "string", Want: []string{"number"}}); ok { + t.Fatal("type mismatch is not a property name") + } + got, ok := propertyFromKind(&kind.Required{Missing: []string{"schemaVersion"}}) + if !ok || got != "schemaVersion" { + t.Fatalf("required single = %q ok=%v", got, ok) + } + got, ok = propertyFromKind(&kind.AdditionalProperties{Properties: []string{"secretLeak"}}) + if !ok || got != "secretLeak" { + t.Fatalf("additional single = %q ok=%v", got, ok) + } +} + +func TestJSONPointerEscapes(t *testing.T) { + t.Parallel() + if jsonPointer(nil) != "" { + t.Fatal("empty tokens") + } + got := jsonPointer([]string{"a/b", "c~d"}) + if got != "/a~1b/c~0d" { + t.Fatalf("got %q", got) + } +} + +func TestJoinPointerRootAndNested(t *testing.T) { + t.Parallel() + if joinPointer("", "x") != "/x" { + t.Fatal("root") + } + if joinPointer("/metadata", "enviromentId") != "/metadata/enviromentId" { + t.Fatal("nested") + } +} + +func TestPointeredResultFallbackWhenNoLeaves(t *testing.T) { + t.Parallel() + // A ValidationError with a cause that is nil is skipped; the parent then + // has Causes != 0 so collectLeaves adds nothing, and pointeredResult + // must still emit one error (the empty-leaves fallback). + err := pointeredResult(&jsonschema.ValidationError{ + Causes: []*jsonschema.ValidationError{nil}, + }) + var res *Result + if !errors.As(err, &res) || len(res.Errors) != 1 { + t.Fatalf("got %v", err) + } + if res.Errors[0].Message != "validation failed" { + t.Fatalf("fallback message = %q", res.Errors[0].Message) + } +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 3978f9a..1bac0e7 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -12,12 +12,17 @@ import ( "sync" "github.com/platformrelay/kollect-render/schema" - "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/santhosh-tekuri/jsonschema/v6/kind" + "golang.org/x/text/language" + "golang.org/x/text/message" "gopkg.in/yaml.v3" ) const schemaURL = "https://github.com/platformrelay/kollect-render/schema/inventory-v0.schema.json" +var schemaPrinter = message.NewPrinter(language.English) + // Error is a single schema violation located by JSON Pointer. type Error struct { Pointer string @@ -107,8 +112,13 @@ func decodeDocument(raw []byte) (any, error) { func loadSchema() (*jsonschema.Schema, error) { schemaOnce.Do(func() { c := jsonschema.NewCompiler() - c.AssertFormat = true - if err := c.AddResource(schemaURL, bytes.NewReader(schema.InventoryV0)); err != nil { + c.AssertFormat() + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schema.InventoryV0)) + if err != nil { + schemaErr = fmt.Errorf("decode schema: %w", err) + return + } + if err := c.AddResource(schemaURL, doc); err != nil { schemaErr = fmt.Errorf("add schema resource: %w", err) return } @@ -128,8 +138,7 @@ func pointeredResult(err error) error { res := &Result{} collectLeaves(ve, res) if len(res.Errors) == 0 { - msg := ve.Message - res.Errors = append(res.Errors, Error{Pointer: pointerFor(ve.InstanceLocation, msg), Message: msg}) + res.Errors = append(res.Errors, Error{Pointer: pointerFor(ve), Message: validationMessage(ve)}) } return res } @@ -139,12 +148,7 @@ func collectLeaves(ve *jsonschema.ValidationError, res *Result) { return } if len(ve.Causes) == 0 { - msg := ve.Message - if msg == "" { - msg = ve.Error() - } - ptr := pointerFor(ve.InstanceLocation, msg) - res.Errors = append(res.Errors, Error{Pointer: ptr, Message: msg}) + res.Errors = append(res.Errors, Error{Pointer: pointerFor(ve), Message: validationMessage(ve)}) return } for _, cause := range ve.Causes { @@ -152,42 +156,51 @@ func collectLeaves(ve *jsonschema.ValidationError, res *Result) { } } -func pointerFor(loc, msg string) string { - base := normalizePointer(loc) - if prop, ok := propertyFromMessage(msg); ok { +func validationMessage(ve *jsonschema.ValidationError) string { + if ve == nil { + return "validation failed" + } + if ve.ErrorKind != nil { + return ve.ErrorKind.LocalizedString(schemaPrinter) + } + return "validation failed" +} + +func pointerFor(ve *jsonschema.ValidationError) string { + base := jsonPointer(ve.InstanceLocation) + if prop, ok := propertyFromKind(ve.ErrorKind); ok { return joinPointer(base, prop) } return base } -func propertyFromMessage(msg string) (string, bool) { - // jsonschema v5 often names the offending key in the message while - // InstanceLocation points at the parent object (or the document root). - const ( - additionalPrefix = "additionalProperties '" - missingPrefix = "missing properties: '" - ) - switch { - case strings.Contains(msg, additionalPrefix): - return extractQuotedAfter(msg, additionalPrefix) - case strings.Contains(msg, missingPrefix): - return extractQuotedAfter(msg, missingPrefix) - default: - return "", false +func jsonPointer(tokens []string) string { + if len(tokens) == 0 { + return "" } + var b strings.Builder + for _, tok := range tokens { + b.WriteByte('/') + b.WriteString(escapePointerToken(tok)) + } + return b.String() } -func extractQuotedAfter(msg, prefix string) (string, bool) { - i := strings.Index(msg, prefix) - if i < 0 { +func propertyFromKind(k jsonschema.ErrorKind) (string, bool) { + if k == nil { return "", false } - rest := msg[i+len(prefix):] - end := strings.IndexByte(rest, '\'') - if end <= 0 { - return "", false + switch typed := k.(type) { + case *kind.Required: + if len(typed.Missing) == 1 { + return typed.Missing[0], true + } + case *kind.AdditionalProperties: + if len(typed.Properties) == 1 { + return typed.Properties[0], true + } } - return rest[:end], true + return "", false } func joinPointer(base, prop string) string { @@ -203,13 +216,3 @@ func escapePointerToken(s string) string { s = strings.ReplaceAll(s, "/", "~1") return s } - -func normalizePointer(loc string) string { - if loc == "" { - return "" - } - if strings.HasPrefix(loc, "/") { - return loc - } - return "/" + loc -}